SHA256
Compare commits
58
Commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
51626f65c7
|
||
|
|
65669d5afd
|
||
|
|
5929b5ddbe
|
||
|
|
a7c64e6c27
|
||
|
|
36c569057c
|
||
|
|
b05e18c8bb
|
||
|
|
888663aa86
|
||
|
|
1a8fcaec7d
|
||
|
|
394fe00b25
|
||
|
|
48a56cd3f2
|
||
|
|
63f38883c8
|
||
|
|
24ec6aa1d0
|
||
|
|
44caf92ada | ||
|
|
b3e50cd50e
|
||
|
|
1d4b1cdd46
|
||
|
|
98cf00578d | ||
|
|
b6f1c0e4d2 | ||
|
|
477b46a8dc
|
||
|
|
6e348ecae3
|
||
|
|
b15903f1e0
|
||
|
|
6c0347609d
|
||
|
|
92a4654f14
|
||
|
|
ec5b2f0ab6
|
||
|
|
85575d3185
|
||
|
|
2277025829
|
||
|
|
5e9c0272dd
|
||
|
|
e3cb2263d9
|
||
|
|
4c68b90f9d | ||
|
|
e9b9bac300 | ||
|
|
87fbcd70ae | ||
|
|
ea1a193937 | ||
|
|
d42c434780
|
||
|
|
363130e87b
|
||
|
|
8014975c81
|
||
|
|
f964b685c8
|
||
|
|
573e540ba4 | ||
|
|
a237814abc
|
||
|
|
7100609809
|
||
|
|
43763b0b35 | ||
|
|
4097e14e7a | ||
|
|
463d95d16f | ||
|
|
c24f9afbae
|
||
|
|
25c38707c3
|
||
|
|
111f5cf792
|
||
|
|
5ffd73052d
|
||
|
|
003319fa26
|
||
|
|
257592f3c1
|
||
|
|
f14942a287
|
||
|
|
27be2141f7 | ||
|
|
4e67cb9b09 | ||
|
|
6840395041 | ||
|
|
642265f2e7 | ||
|
|
f81acd8152 | ||
|
|
2d88a0d99c | ||
|
|
2c334edec6
|
||
|
|
7e5a92162b
|
||
|
|
838b0fccfd | ||
|
|
72ba78579a |
No files matched your search
@@ -42,3 +42,4 @@ GEMINI.md
|
||||
# Developer Description Files
|
||||
/AsyncInputAutoplayFix.md
|
||||
/AudioMixerExplanation.md
|
||||
/O5Theme.md
|
||||
+82
-29
@@ -3,17 +3,22 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.1</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<NoWarn>MSB3277</NoWarn>
|
||||
<NoWarn>MSB3277;1591</NoWarn>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<EnableSourceControlManagerQueries>false</EnableSourceControlManagerQueries>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
|
||||
<LangVersion>10</LangVersion>
|
||||
<LangVersion>11</LangVersion>
|
||||
<PackageLicenseExpression>GPL-3.0-or-later</PackageLicenseExpression>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="0Harmony">
|
||||
<HintPath>$(ADOFAI_PATH)\MelonLoader\net35\0Harmony.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="O5Kit">
|
||||
<HintPath>$(ADOFAI_PATH)\UserLibs\O5Kit.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Assembly-CSharp">
|
||||
<HintPath>$(ADOFAI_PATH)\A Dance of Fire and Ice_Data\Managed\Assembly-CSharp.dll</HintPath>
|
||||
</Reference>
|
||||
@@ -79,34 +84,82 @@
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Remove="Bundle\Win64\fonts" />
|
||||
<EmbeddedResource Include="Bundle\Win64\fonts" />
|
||||
<None Remove="Bundle\Win64\scenes" />
|
||||
<EmbeddedResource Include="Bundle\Win64\scenes" />
|
||||
<None Remove="Bundle\Win64\shaders" />
|
||||
<EmbeddedResource Include="Bundle\Win64\shaders" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Remove="Bundle\Linux\fonts" />
|
||||
<EmbeddedResource Include="Bundle\Linux\fonts" />
|
||||
<None Remove="Bundle\Linux\scenes" />
|
||||
<EmbeddedResource Include="Bundle\Linux\scenes" />
|
||||
<None Remove="Bundle\Linux\shaders" />
|
||||
<EmbeddedResource Include="Bundle\Linux\shaders" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Remove="Bundle\OSX\fonts" />
|
||||
<EmbeddedResource Include="Bundle\OSX\fonts" />
|
||||
<None Remove="Bundle\OSX\scenes" />
|
||||
<EmbeddedResource Include="Bundle\OSX\scenes" />
|
||||
<None Remove="Bundle\OSX\shaders" />
|
||||
<EmbeddedResource Include="Bundle\OSX\shaders" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FFmpeg.AutoGen" Version="8.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Localization/Locale/*.json" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Bundle/" />
|
||||
<EmbeddedResource Include="Bundle/Linux/*" />
|
||||
<EmbeddedResource Include="Bundle/Win64/*" />
|
||||
<EmbeddedResource Include="Bundle/OSX/*" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Build 후 XML 문서에서 ADOFAIRenderer.API 네임스페이스만 남기고 나머지 제거 -->
|
||||
<UsingTask TaskName="FilterXmlDoc" TaskFactory="RoslynCodeTaskFactory" AssemblyFile="$(MSBuildToolsPath)\Microsoft.Build.Tasks.Core.dll">
|
||||
<ParameterGroup>
|
||||
<XmlFilePath ParameterType="System.String" Required="true" />
|
||||
<TargetNamespace ParameterType="System.String" Required="true" />
|
||||
</ParameterGroup>
|
||||
<Task>
|
||||
<Using Namespace="System" />
|
||||
<Using Namespace="System.IO" />
|
||||
<Using Namespace="System.Collections.Generic" />
|
||||
<Using Namespace="System.Xml.Linq" />
|
||||
<Code Type="Fragment" Language="cs">
|
||||
<![CDATA[
|
||||
if (!File.Exists(XmlFilePath)) return true;
|
||||
try
|
||||
{
|
||||
var doc = XDocument.Load(XmlFilePath);
|
||||
var members = doc.Root?.Element("members");
|
||||
if (members != null)
|
||||
{
|
||||
var prefix = TargetNamespace + ".";
|
||||
var toRemove = new List<XElement>();
|
||||
|
||||
foreach (var member in members.Elements("member"))
|
||||
{
|
||||
var nameAttr = member.Attribute("name")?.Value;
|
||||
if (string.IsNullOrEmpty(nameAttr)) continue;
|
||||
|
||||
// XML 주석 형식: T:네임스페이스.타입, M:메서드, P:프로퍼티, F:필드/열거형, E:이벤트 등
|
||||
int colonIndex = nameAttr.IndexOf(':');
|
||||
string fullName = colonIndex >= 0 ? nameAttr.Substring(colonIndex + 1) : nameAttr;
|
||||
|
||||
if (!fullName.StartsWith(prefix, StringComparison.Ordinal) && !fullName.Equals(TargetNamespace, StringComparison.Ordinal))
|
||||
{
|
||||
toRemove.Add(member);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var elem in toRemove)
|
||||
{
|
||||
elem.Remove();
|
||||
}
|
||||
|
||||
doc.Save(XmlFilePath);
|
||||
Log.LogMessage(Microsoft.Build.Framework.MessageImportance.High, $"[FilterXmlDoc] Kept '{TargetNamespace}' namespace docs. Removed {toRemove.Count} other members.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.LogWarning($"[FilterXmlDoc] XML filtering failed: {ex.Message}");
|
||||
}
|
||||
]]>
|
||||
</Code>
|
||||
</Task>
|
||||
</UsingTask>
|
||||
|
||||
<Target Name="FilterDocumentationFile" AfterTargets="Build" Condition="'$(GenerateDocumentationFile)' == 'true'">
|
||||
<PropertyGroup>
|
||||
<_XmlDocPath Condition="'$(_XmlDocPath)' == '' and Exists('$(TargetDir)$(TargetName).xml')">$(TargetDir)$(TargetName).xml</_XmlDocPath>
|
||||
<_XmlDocPath Condition="'$(_XmlDocPath)' == '' and Exists('$(DocumentationFile)')">$(DocumentationFile)</_XmlDocPath>
|
||||
</PropertyGroup>
|
||||
<FilterXmlDoc Condition="'$(_XmlDocPath)' != ''" XmlFilePath="$(_XmlDocPath)" TargetNamespace="ADOFAIRenderer.API" />
|
||||
</Target>
|
||||
</Project>
|
||||
@@ -0,0 +1,122 @@
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
|
||||
namespace ADOFAIRenderer.API;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the result and metadata of a completed or canceled render session.
|
||||
/// </summary>
|
||||
[SuppressMessage("ReSharper", "UnusedType.Global")]
|
||||
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
|
||||
public class RenderResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Indicates whether the rendering session completed successfully.
|
||||
/// </summary>
|
||||
public bool Success { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The absolute output path of the rendered video file. Empty string if rendering failed or output was not created.
|
||||
/// </summary>
|
||||
public string OutputPath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Total number of video frames sent to the encoder.
|
||||
/// </summary>
|
||||
public long TotalFrames { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The calculated video duration in seconds (based on TotalFrames and FPS).
|
||||
/// </summary>
|
||||
public double VideoDuration { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Total wall-clock time spent rendering.
|
||||
/// </summary>
|
||||
public TimeSpan RenderDuration { get; }
|
||||
|
||||
/// <summary>
|
||||
/// File size of the output video in bytes (0 if failed or file does not exist).
|
||||
/// </summary>
|
||||
public long FileSizeBytes { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message or reason if rendering failed or was canceled; <c>null</c> on success.
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RenderResult"/> class.
|
||||
/// </summary>
|
||||
/// <param name="success">Whether the render session completed successfully.</param>
|
||||
/// <param name="outputPath">The absolute output path of the rendered video file.</param>
|
||||
/// <param name="totalFrames">Total number of video frames sent to the encoder.</param>
|
||||
/// <param name="videoDuration">Calculated video duration in seconds.</param>
|
||||
/// <param name="renderDuration">Total wall-clock time spent rendering.</param>
|
||||
/// <param name="fileSizeBytes">File size of the output video in bytes.</param>
|
||||
/// <param name="errorMessage">Error message or reason if failed or canceled; otherwise, <c>null</c>.</param>
|
||||
public RenderResult(
|
||||
bool success,
|
||||
string outputPath,
|
||||
long totalFrames,
|
||||
double videoDuration,
|
||||
TimeSpan renderDuration,
|
||||
long fileSizeBytes = 0,
|
||||
string? errorMessage = null)
|
||||
{
|
||||
Success = success;
|
||||
OutputPath = outputPath;
|
||||
TotalFrames = totalFrames;
|
||||
VideoDuration = videoDuration;
|
||||
RenderDuration = renderDuration;
|
||||
FileSizeBytes = fileSizeBytes;
|
||||
ErrorMessage = errorMessage;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful <see cref="RenderResult"/> instance from the specified render metrics.
|
||||
/// </summary>
|
||||
/// <param name="outputPath">The path to the rendered video file.</param>
|
||||
/// <param name="totalFrames">Total number of video frames sent to the encoder.</param>
|
||||
/// <param name="fps">The effective frames per second used to compute video duration.</param>
|
||||
/// <param name="renderDuration">Total wall-clock time spent rendering.</param>
|
||||
/// <returns>A new <see cref="RenderResult"/> representing a successful render session.</returns>
|
||||
internal static RenderResult CreateSuccess(string outputPath, long totalFrames, double fps, TimeSpan renderDuration)
|
||||
{
|
||||
var fileSizeBytes = 0L;
|
||||
if (!string.IsNullOrEmpty(outputPath) && File.Exists(outputPath))
|
||||
{
|
||||
try { fileSizeBytes = new FileInfo(outputPath).Length; }
|
||||
catch { /* ignored */ }
|
||||
}
|
||||
|
||||
var videoDuration = fps > 0 ? totalFrames / fps : 0.0;
|
||||
return new RenderResult(true, outputPath, totalFrames, videoDuration, renderDuration, fileSizeBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed or cancelled <see cref="RenderResult"/> instance.
|
||||
/// </summary>
|
||||
/// <param name="errorMessage">Error message or reason for failure/cancellation.</param>
|
||||
/// <param name="totalFrames">Total number of video frames sent to the encoder prior to termination.</param>
|
||||
/// <param name="fps">The effective frames per second used to compute video duration.</param>
|
||||
/// <param name="renderDuration">Total wall-clock time spent rendering prior to termination.</param>
|
||||
/// <returns>A new <see cref="RenderResult"/> representing a failed or canceled render session.</returns>
|
||||
internal static RenderResult CreateFailure(string? errorMessage, long totalFrames, double fps, TimeSpan renderDuration)
|
||||
{
|
||||
var videoDuration = fps > 0 ? totalFrames / fps : 0.0;
|
||||
return new RenderResult(false, string.Empty, totalFrames, videoDuration, renderDuration, 0, errorMessage ?? "Rendering failed or was cancelled.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a string summarizing the render result metrics or failure reason.
|
||||
/// </summary>
|
||||
/// <returns>A string representation of the <see cref="RenderResult"/>.</returns>
|
||||
public override string ToString()
|
||||
{
|
||||
return Success
|
||||
? $"[RenderResult: Success, Output={OutputPath}, Frames={TotalFrames}, VideoDuration={VideoDuration:F2}s, Time={RenderDuration.TotalSeconds:F1}s, Size={FileSizeBytes / 1024.0 / 1024.0:F2}MB]"
|
||||
: $"[RenderResult: Failed/Cancelled, Error={ErrorMessage ?? "Unknown"}]";
|
||||
}
|
||||
}
|
||||
+48
-4
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using ADOFAIRenderer.Core;
|
||||
using ADOFAIRenderer.Data;
|
||||
@@ -9,6 +10,7 @@ namespace ADOFAIRenderer.API;
|
||||
/// <summary>
|
||||
/// Fluent builder for configuring and starting a render session.
|
||||
/// </summary>
|
||||
[SuppressMessage("ReSharper", "UnusedType.Global")]
|
||||
public class RenderSession
|
||||
{
|
||||
private bool _auto = true;
|
||||
@@ -31,10 +33,14 @@ public class RenderSession
|
||||
|
||||
private bool _timePatch = true;
|
||||
private bool _screenPatch = true;
|
||||
private Func<bool> _endCondition = () => scrController.instance.state == States.Won;
|
||||
|
||||
private string? _outputPath;
|
||||
|
||||
private bool _notification = true;
|
||||
|
||||
private Func<bool>? _endCondition;
|
||||
private Action<RenderResult>? _onComplete;
|
||||
private Action? _onLastUpdate;
|
||||
|
||||
/// <summary>
|
||||
/// Sets whether auto mode is enabled.
|
||||
/// </summary>
|
||||
@@ -87,23 +93,61 @@ public class RenderSession
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets whether desktop notifications should be displayed when rendering completes.
|
||||
/// </summary>
|
||||
/// <param name="enabled"><c>true</c> to enable desktop notifications; otherwise, <c>false</c>.</param>
|
||||
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
||||
public RenderSession WithNotification(bool enabled)
|
||||
{
|
||||
_notification = enabled;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a callback action to be invoked when the render session completes or fails.
|
||||
/// </summary>
|
||||
/// <param name="callback">The callback action that receives the resulting <see cref="RenderResult"/>.</param>
|
||||
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
||||
public RenderSession OnComplete(Action<RenderResult> callback)
|
||||
{
|
||||
_onComplete += callback;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a callback action to be invoked on the last update of each frame in the render session.
|
||||
/// </summary>
|
||||
/// <param name="callback">The callback action to execute.</param>
|
||||
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
||||
public RenderSession OnLastUpdate(Action callback)
|
||||
{
|
||||
_onLastUpdate += callback;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the configured flags and initiates the render session.
|
||||
/// </summary>
|
||||
/// <param name="renderConfig">The configuration settings for the render session.</param>
|
||||
/// <param name="levelPath">The file path of the level to render.</param>
|
||||
/// <exception cref="InvalidOperationException">Thrown when the output directory cannot be determined from the output path.</exception>
|
||||
public void Start(RenderConfig renderConfig, string levelPath)
|
||||
{
|
||||
RenderContext.Auto = _auto;
|
||||
RenderContext.TimePatch = _timePatch;
|
||||
RenderContext.ScreenPatch = _screenPatch;
|
||||
RenderContext.EndCondition = _endCondition;
|
||||
RenderContext.OutputPath = _outputPath;
|
||||
RenderContext.Notification = _notification;
|
||||
|
||||
if (_endCondition is not null) RenderContext.EndCondition = _endCondition;
|
||||
if (_onComplete is not null) RenderContext.OnComplete = _onComplete;
|
||||
if (_onLastUpdate is not null) RenderContext.OnLastUpdate = _onLastUpdate;
|
||||
|
||||
RenderManager.Instance.levelPath = levelPath;
|
||||
RenderManager.Instance.Configuration = renderConfig;
|
||||
|
||||
RenderManager.Instance.StartLoadingOverride();
|
||||
RenderManager.Instance.stage = RenderStage.Load;
|
||||
|
||||
if (_outputPath is null) RenderManager.Instance.SetupCaptureDirectory();
|
||||
else FileUtil.CreateDirectory(Path.GetDirectoryName(_outputPath) ?? throw new InvalidOperationException());
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+11
-12
@@ -1,11 +1,11 @@
|
||||
using ADOFAIRenderer.UI;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ADOFAIRenderer.Core
|
||||
namespace ADOFAIRenderer.Core;
|
||||
|
||||
[DefaultExecutionOrder(int.MinValue)]
|
||||
internal class FirstUpdateHook : MonoBehaviour
|
||||
{
|
||||
[DefaultExecutionOrder(int.MinValue)]
|
||||
internal class FirstUpdateHook : MonoBehaviour
|
||||
{
|
||||
private bool _checkedFFmpeg;
|
||||
private bool _checkedNvidiaDriver;
|
||||
|
||||
@@ -33,7 +33,7 @@ namespace ADOFAIRenderer.Core
|
||||
|
||||
if (RenderManager.Instance)
|
||||
{
|
||||
RenderManager.Instance.OnPreRenderUpdate();
|
||||
RenderManager.Instance.OnPreUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,22 +45,21 @@ namespace ADOFAIRenderer.Core
|
||||
var currentVersion = driverVer.Value.ToString("F2");
|
||||
|
||||
CommonWarningDialogUI.ShowDialog(
|
||||
"NVIDIA 그래픽 드라이버 업데이트 권장",
|
||||
$"현재 감지된 NVIDIA 그래픽 드라이버 버전: <b><color=#ff5555>{currentVersion}</color></b>\n\n하드웨어 가속(NVENC)을 사용하여 영상을 렌더링하려면 드라이버 버전 <b><color=#55ff55>610.00 이상</color></b>이 필요합니다.\n\n드라이버 버전이 낮으면 인코딩이 실패하거나 소프트웨어(CPU) 인코딩으로 대체되어 렌더링이 매우 느려집니다. 드라이버 업데이트를 진행해 주시기 바랍니다.",
|
||||
new DialogButton("드라이버 업데이트", () =>
|
||||
"NVIDIA Graphics Driver Update Recommended",
|
||||
$"Currently detected NVIDIA graphics driver version: <b><color=#ff5555>{currentVersion}</color></b>\n\nDriver version <b><color=#55ff55>610.00 or higher</color></b> is required to render video using hardware acceleration (NVENC).\n\nIf the driver version is lower, encoding may fail or fallback to software (CPU) encoding, significantly slowing down rendering. Please update your graphics driver.",
|
||||
new DialogButton("Update Driver", () =>
|
||||
{
|
||||
Application.OpenURL("https://www.nvidia.com/Download/index.aspx");
|
||||
Application.Quit();
|
||||
}, new Color(0.0f, 0.8f, 1.0f, 1.0f)),
|
||||
new DialogButton("기능 비활성화", () =>
|
||||
new DialogButton("Disable Feature", () =>
|
||||
{
|
||||
RenderMod.IsFeatureDisabled = true;
|
||||
}),
|
||||
new DialogButton("이번만 그냥 진행", () =>
|
||||
new DialogButton("Continue Anyway", () =>
|
||||
{
|
||||
// 아무 동작 안 함
|
||||
// ignored
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,11 @@ using System.Reflection;
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ADOFAIRenderer.Core
|
||||
namespace ADOFAIRenderer.Core;
|
||||
|
||||
[DefaultExecutionOrder(int.MaxValue)]
|
||||
internal class PostRenderHook : MonoBehaviour
|
||||
{
|
||||
[DefaultExecutionOrder(int.MaxValue)]
|
||||
internal class PostRenderHook : MonoBehaviour
|
||||
{
|
||||
private FieldInfo? _camQuadMeshField;
|
||||
|
||||
private void Awake()
|
||||
@@ -31,5 +31,4 @@ namespace ADOFAIRenderer.Core
|
||||
if (RenderManager.Instance)
|
||||
RenderManager.Instance.OnPostFrame(val.sharedMaterial.mainTexture as RenderTexture);
|
||||
}
|
||||
}
|
||||
}
|
||||
+137
-158
@@ -4,52 +4,84 @@ using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Buffers;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using ADOFAIRenderer.API;
|
||||
using ADOFAIRenderer.Data;
|
||||
using ADOFAIRenderer.UI;
|
||||
using ADOFAIRenderer.UI.Window;
|
||||
using ADOFAIRenderer.Util;
|
||||
using ADOFAIRenderer.Util.Audio;
|
||||
using ADOFAIRenderer.Util.FFmpeg;
|
||||
using FFmpeg.AutoGen;
|
||||
using HarmonyLib;
|
||||
using MelonLoader;
|
||||
using MelonLoader.Utils;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Rendering;
|
||||
using Random = UnityEngine.Random;
|
||||
using ThreadPriority = System.Threading.ThreadPriority;
|
||||
|
||||
namespace ADOFAIRenderer.Core
|
||||
namespace ADOFAIRenderer.Core;
|
||||
|
||||
internal enum RenderStage
|
||||
{
|
||||
None, Load, OnScene, Render
|
||||
}
|
||||
|
||||
internal static class RenderContext
|
||||
{
|
||||
internal static class RenderContext
|
||||
{
|
||||
public static bool Auto = true;
|
||||
public static bool TimePatch = true;
|
||||
public static bool ScreenPatch = true;
|
||||
public static Func<bool> EndCondition = () => scrController.instance.state == States.Won;
|
||||
public static string? OutputPath;
|
||||
|
||||
public static bool Notification = true;
|
||||
|
||||
private static readonly Func<bool> EndConditionDefault = () => scrController.instance.state == States.Won;
|
||||
public static Func<bool> EndCondition = EndConditionDefault;
|
||||
|
||||
private static readonly Action<RenderResult> OnCompleteDefault = result =>
|
||||
{
|
||||
if (result.Success && Notification)
|
||||
{
|
||||
var fileString = !string.IsNullOrEmpty(RenderManager.Instance.CurrentOutPath) ?
|
||||
$"'{Path.GetFileName(RenderManager.Instance.CurrentOutPath)}'" : "Video";
|
||||
|
||||
var renderFolder = Path.GetDirectoryName(RenderManager.Instance.CurrentOutPath);
|
||||
OSNotification.Show("Rendering Complete!", $"{fileString} saved successfully.", renderFolder);
|
||||
}
|
||||
};
|
||||
public static Action<RenderResult> OnComplete = OnCompleteDefault;
|
||||
|
||||
private static readonly Action OnLastUpdateDefault = () => { };
|
||||
public static Action OnLastUpdate = OnLastUpdateDefault;
|
||||
|
||||
public static void Reset()
|
||||
{
|
||||
Auto = true;
|
||||
TimePatch = true; ScreenPatch = true;
|
||||
EndCondition = () => scrController.instance.state == States.Won;
|
||||
OutputPath = null;
|
||||
}
|
||||
}
|
||||
|
||||
internal class RenderManager : MonoBehaviour
|
||||
{
|
||||
Notification = true;
|
||||
|
||||
EndCondition = EndConditionDefault;
|
||||
OnComplete = OnCompleteDefault;
|
||||
OnLastUpdate = OnLastUpdateDefault;
|
||||
}
|
||||
}
|
||||
|
||||
internal class RenderManager : MonoBehaviour
|
||||
{
|
||||
// Singleton Accessors
|
||||
public static RenderManager Instance { get; private set; } = null!;
|
||||
public static bool IsActive => Instance && Instance.IsRendering;
|
||||
public static bool IsCapturingActive => Instance && Instance.IsCapturing;
|
||||
public static bool IsActive => Instance && Instance.stage >= RenderStage.OnScene;
|
||||
public static bool IsCapturingActive => Instance && Instance.stage >= RenderStage.Render;
|
||||
|
||||
public const int MaxQueuedFrames = 64;
|
||||
public const int MaxRenderRetry = 64;
|
||||
|
||||
// Statistics
|
||||
public long framesSent;
|
||||
private readonly Stopwatch _renderStopwatch = new();
|
||||
|
||||
// Core Render Configuration and Path
|
||||
public int countdown;
|
||||
@@ -60,9 +92,7 @@ namespace ADOFAIRenderer.Core
|
||||
public RenderConfig Configuration = null!;
|
||||
|
||||
// Render Sessions State (OOP Instance Fields)
|
||||
public bool OverrideLoading { get; private set; }
|
||||
private bool IsRendering { get; set; }
|
||||
private bool IsCapturing { get; set; }
|
||||
public RenderStage stage = RenderStage.None;
|
||||
public double timeBase;
|
||||
public double unscaledTimeBase;
|
||||
public double dspTimeBase;
|
||||
@@ -72,8 +102,6 @@ namespace ADOFAIRenderer.Core
|
||||
private AudioSyncCalculator SyncCalculator { get; set; } = null!;
|
||||
|
||||
private FrameReader? _frameReader;
|
||||
public bool renderCheck = true;
|
||||
public int skipCount;
|
||||
|
||||
public long frameCountForVideo;
|
||||
public long lastVideoFrameIndex = -1;
|
||||
@@ -97,7 +125,7 @@ namespace ADOFAIRenderer.Core
|
||||
public long passCount;
|
||||
public bool endRequested;
|
||||
public bool endSuccessful;
|
||||
public long wonFrame = -1;
|
||||
public long endFrame = -1;
|
||||
|
||||
// Private Instance Fields
|
||||
private FFmpegNativeEncoder? _activeVideoFFmpeg;
|
||||
@@ -105,7 +133,7 @@ namespace ADOFAIRenderer.Core
|
||||
private int _originalInputOffset;
|
||||
private GameObject? _debugMonitorObj;
|
||||
private GameObject? _escapeHandlerObj;
|
||||
private string? _currentOutPath;
|
||||
internal string? CurrentOutPath;
|
||||
private bool _renderSetup;
|
||||
|
||||
private void Awake()
|
||||
@@ -118,40 +146,10 @@ namespace ADOFAIRenderer.Core
|
||||
CleanUpActiveRender();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets overrideLoading to true when starting to load the render scene.
|
||||
/// </summary>
|
||||
public void StartLoadingOverride()
|
||||
{
|
||||
OverrideLoading = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets isRendering to true when starting the rendering setup.
|
||||
/// </summary>
|
||||
public void StartRendering()
|
||||
{
|
||||
IsRendering = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets isCapturing to true and resets the cancellation flag when starting the actual frame capture.
|
||||
/// </summary>
|
||||
public void StartCapturing()
|
||||
{
|
||||
IsCapturing = true;
|
||||
IsRenderCancelled = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets all rendering-related states.
|
||||
/// </summary>
|
||||
public void ResetRenderState(bool success)
|
||||
{
|
||||
IsRendering = false;
|
||||
IsCapturing = false;
|
||||
OverrideLoading = false;
|
||||
IsRenderCancelled = !success;
|
||||
stage = RenderStage.None;
|
||||
}
|
||||
|
||||
public void CleanUpActiveRender()
|
||||
@@ -162,7 +160,7 @@ namespace ADOFAIRenderer.Core
|
||||
|
||||
if (_activeVideoFFmpeg != null)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Msg("게임 종료 또는 RenderManager 파괴 감지: FFmpeg 네이티브 인코더 리소스를 해제합니다.");
|
||||
Melon<RenderMod>.Logger.Msg("Game exit or RenderManager destruction detected: Disposing FFmpeg native encoder resources.");
|
||||
_activeVideoFFmpeg.Dispose();
|
||||
_activeVideoFFmpeg = null;
|
||||
}
|
||||
@@ -171,7 +169,7 @@ namespace ADOFAIRenderer.Core
|
||||
{
|
||||
if (_allVideoFFmpegInstances.Count > 0)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Msg($"게임 종료 감지: 생성된 모든 FFmpeg 인코더 인스턴스({_allVideoFFmpegInstances.Count}개)를 수거 및 해제합니다.");
|
||||
Melon<RenderMod>.Logger.Msg($"Game exit detected: Cleaning up and disposing all created FFmpeg encoder instances ({_allVideoFFmpegInstances.Count}).");
|
||||
foreach (var instance in _allVideoFFmpegInstances)
|
||||
{
|
||||
try
|
||||
@@ -180,7 +178,7 @@ namespace ADOFAIRenderer.Core
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
_allVideoFFmpegInstances.Clear();
|
||||
@@ -221,9 +219,8 @@ namespace ADOFAIRenderer.Core
|
||||
scrController.instance.noFailInfiniteMargin = true;
|
||||
}
|
||||
|
||||
StartRendering();
|
||||
countdown = 3;
|
||||
wonFrame = -1;
|
||||
stage = RenderStage.OnScene;
|
||||
countdown = 3; endFrame = -1;
|
||||
|
||||
if (!_escapeHandlerObj)
|
||||
{
|
||||
@@ -236,7 +233,7 @@ namespace ADOFAIRenderer.Core
|
||||
scrController.instance!.StartCoroutine(SetupPlay());
|
||||
}
|
||||
|
||||
if (!IsRendering) return;
|
||||
if (Instance.stage < RenderStage.OnScene) return;
|
||||
if (!scrController.instance || scrController.instance.state == States.Fail || scrController.instance.state == States.Fail2)
|
||||
{
|
||||
ResetRenderState(success: false);
|
||||
@@ -245,93 +242,47 @@ namespace ADOFAIRenderer.Core
|
||||
}
|
||||
else if (RenderContext.EndCondition())
|
||||
{
|
||||
if (wonFrame == -1)
|
||||
if (endFrame == -1)
|
||||
{
|
||||
wonFrame = frameCount;
|
||||
endFrame = frameCount;
|
||||
}
|
||||
|
||||
var extraFrames = (long)Math.Ceiling(Configuration.DelayAfterEnd * Configuration.RenderFPS);
|
||||
|
||||
if (frameCount - wonFrame < extraFrames) return;
|
||||
if (frameCount - endFrame < extraFrames) return;
|
||||
ResetRenderState(success: true);
|
||||
StopFFmpeg(true);
|
||||
}
|
||||
}
|
||||
|
||||
public void OnPreRenderUpdate()
|
||||
public void OnPreUpdate()
|
||||
{
|
||||
if (!IsRendering) return;
|
||||
|
||||
// [렉으로 인한 프레임 렌더링 스킵 시 재시도 로직 (원본 모드 이식)]
|
||||
if (renderCheck && IsCapturing)
|
||||
{
|
||||
if (skipCount < MaxRenderRetry)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Warning($"[Render Skipped] Frame {frameCount} rendering missed. Retrying... (Attempt {skipCount + 1}/{MaxRenderRetry})");
|
||||
skipCount++;
|
||||
Time.timeScale = 0f; // 진행 시간 멈추기 (재시도)
|
||||
return; // 업데이트 진행하지 않고 대기
|
||||
}
|
||||
|
||||
// 60회 초과 시 최후의 블랙 프레임 삽입하여 전체 파이프라인 마비 방지
|
||||
Melon<RenderMod>.Logger.Error($"[Render Skipped] Frame {frameCount} rendering failed after 60 retries. Supplying black frame.");
|
||||
|
||||
var currentFrame = frameCountForVideo++;
|
||||
var renderFPS = Configuration.RenderFPS;
|
||||
var videoFPS = Configuration.VideoFPS;
|
||||
var effectiveVideoFps = Math.Min(videoFPS, renderFPS);
|
||||
var videoFrameIndex = (long)(currentFrame * effectiveVideoFps / renderFPS);
|
||||
|
||||
if (videoFrameIndex > lastVideoFrameIndex)
|
||||
{
|
||||
lastVideoFrameIndex = videoFrameIndex;
|
||||
lastRequestedVideoFrameIndex = videoFrameIndex;
|
||||
|
||||
var byteSize = Configuration.RenderWidth * Configuration.RenderHeight * 4;
|
||||
var blackFrame = ArrayPool<byte>.Shared.Rent(byteSize);
|
||||
Array.Clear(blackFrame, 0, byteSize);
|
||||
|
||||
lock (ImageQueue)
|
||||
{
|
||||
ImageQueue[videoFrameIndex] = new CapturedFrame
|
||||
{
|
||||
ManagedBuffer = blackFrame,
|
||||
Length = byteSize
|
||||
};
|
||||
}
|
||||
FrameAvailableEvent.Set();
|
||||
}
|
||||
|
||||
renderCheck = false; // 진행 허용
|
||||
}
|
||||
|
||||
skipCount = 0;
|
||||
renderCheck = true; // 다음 프레임 검사용으로 초기화
|
||||
if (Instance.stage < RenderStage.OnScene) return;
|
||||
frameCount++;
|
||||
}
|
||||
|
||||
public void OnLastUpdate()
|
||||
{
|
||||
if (!OverrideLoading) return;
|
||||
if (Instance.stage < RenderStage.Load) return;
|
||||
if (!Configuration.AdvancedConfig.NonTile) return;
|
||||
foreach (var floor in scrLevelMaker.instance.listFloors
|
||||
.Where(floor => floor.holdRenderer && floor.holdRenderer.m_meshRenderer.enabled))
|
||||
{
|
||||
floor.holdRenderer.m_meshRenderer.enabled = false;
|
||||
}
|
||||
|
||||
TriggerOnLastUpdate();
|
||||
}
|
||||
|
||||
// ReSharper disable once Unity.IncorrectMethodSignature
|
||||
public void OnPostFrame(RenderTexture? texture)
|
||||
{
|
||||
if (!IsCapturing || !texture) return;
|
||||
if (Instance.stage < RenderStage.Render || !texture) return;
|
||||
|
||||
renderCheck = false; // 이번 프레임 정상 렌더링 완료
|
||||
|
||||
// [Backpressure Throttling (2번 작업)]
|
||||
// [Backpressure Throttling]
|
||||
isThrottling = false;
|
||||
|
||||
while (IsCapturing && IsRendering)
|
||||
while (Instance.stage >= RenderStage.Render)
|
||||
{
|
||||
int count;
|
||||
lock (ImageQueue)
|
||||
@@ -385,15 +336,15 @@ namespace ADOFAIRenderer.Core
|
||||
var quitDelegate =
|
||||
(Func<bool>)Delegate.CreateDelegate(typeof(Func<bool>), scnEditor.instance, targetMethod);
|
||||
|
||||
if (Instance) Instance.StartLoadingOverride();
|
||||
Application.wantsToQuit -= quitDelegate;
|
||||
ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "Assets/Scenes/RenderSettingsScene.unity");
|
||||
RenderConfigWindow.CreateWindow();
|
||||
// ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "Assets/Scenes/RenderSettingsScene.unity");
|
||||
});
|
||||
}
|
||||
|
||||
public void SetupCaptureDirectory()
|
||||
{
|
||||
var renderDir = Path.Combine(MelonEnvironment.GameRootDirectory, "Render");
|
||||
var renderDir = GlobalSettings.EffectiveOutputDirectory;
|
||||
FileUtil.CreateDirectory(renderDir);
|
||||
}
|
||||
|
||||
@@ -413,9 +364,10 @@ namespace ADOFAIRenderer.Core
|
||||
|
||||
private IEnumerator SetupPlay()
|
||||
{
|
||||
if (Configuration.AudioConfig.RenderAudio && Configuration.VideoConfig.Codec is Codec.VP9 or Codec.AV1 or Codec.VVC)
|
||||
if (Configuration.AudioConfig.RenderAudio)
|
||||
{
|
||||
Configuration.AudioConfig.AudioSampleRate = 48000;
|
||||
if (!CodecUtil.AvailableSampleRate[CodecUtil.AudioCodecMatch[Configuration.VideoConfig.Codec]].Contains(Configuration.AudioConfig.AudioSampleRate))
|
||||
Configuration.AudioConfig.AudioSampleRate = 48000; // most compatible value
|
||||
}
|
||||
|
||||
for (; countdown > 0; countdown--)
|
||||
@@ -423,8 +375,9 @@ namespace ADOFAIRenderer.Core
|
||||
yield return new WaitForSecondsRealtime(1);
|
||||
}
|
||||
|
||||
frameCount = 0;
|
||||
framesSent = 0;
|
||||
frameCount = 0; framesSent = 0;
|
||||
_renderStopwatch.Restart();
|
||||
|
||||
timeBase = Time.timeAsDouble;
|
||||
unscaledTimeBase = Time.unscaledTimeAsDouble;
|
||||
dspTimeBase = AudioSettings.dspTime;
|
||||
@@ -439,11 +392,11 @@ namespace ADOFAIRenderer.Core
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Error($"인코더 초기화 에러: {ex}");
|
||||
Melon<RenderMod>.Logger.Error($"Encoder initialization error: {ex}");
|
||||
CommonWarningDialogUI.ShowDialog(
|
||||
"인코더 오류",
|
||||
$"비디오 인코더를 초기화할 수 없습니다.\n오류: {ex.Message}",
|
||||
new DialogButton("확인", () =>
|
||||
"Encoder Error",
|
||||
$"Failed to initialize video encoder.\nError: {ex.Message}",
|
||||
new DialogButton("OK", () =>
|
||||
{
|
||||
ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnEditor");
|
||||
})
|
||||
@@ -451,7 +404,7 @@ namespace ADOFAIRenderer.Core
|
||||
yield break;
|
||||
}
|
||||
|
||||
StartCapturing();
|
||||
Instance.stage = RenderStage.Render;
|
||||
scrController.instance.paused = scrController.instance.audioPaused = false;
|
||||
scrController.instance.levelWasSkipped = true;
|
||||
|
||||
@@ -492,8 +445,6 @@ namespace ADOFAIRenderer.Core
|
||||
frameCountForVideo = 0;
|
||||
lastVideoFrameIndex = -1;
|
||||
lastRequestedVideoFrameIndex = -1;
|
||||
renderCheck = true;
|
||||
skipCount = 0;
|
||||
endRequested = false;
|
||||
endSuccessful = false;
|
||||
FrameAvailableEvent.Reset();
|
||||
@@ -502,18 +453,18 @@ namespace ADOFAIRenderer.Core
|
||||
var videoFPS = Configuration.VideoFPS;
|
||||
|
||||
var startTime = DateTime.Now;
|
||||
if (RenderContext.OutputPath != null) _currentOutPath = RenderContext.OutputPath;
|
||||
if (RenderContext.OutputPath != null) CurrentOutPath = RenderContext.OutputPath;
|
||||
else {
|
||||
var fileName = startTime.ToString("yyyy-MM-dd HH-mm-ss") +
|
||||
$".{Configuration.AdvancedConfig.FileExtension.TrimStart('.').ToLower()}";
|
||||
_currentOutPath = Path.Combine(MelonEnvironment.GameRootDirectory, "Render", fileName);
|
||||
CurrentOutPath = Path.Combine(GlobalSettings.EffectiveOutputDirectory, fileName);
|
||||
}
|
||||
|
||||
FFmpegNativeEncoder? videoFFmpeg = null;
|
||||
try
|
||||
{
|
||||
var effectiveVideoFps = Math.Min(videoFPS, renderFPS);
|
||||
var codecId = AudioCodecMatch[Configuration.VideoConfig.Codec];
|
||||
var codecId = CodecUtil.AudioCodecMatch[Configuration.VideoConfig.Codec];
|
||||
var settings = new FFmpegNativeEncoder.EncoderSettings
|
||||
{
|
||||
Width = Configuration.RenderWidth,
|
||||
@@ -543,7 +494,7 @@ namespace ADOFAIRenderer.Core
|
||||
{
|
||||
_allVideoFFmpegInstances.Add(videoFFmpeg);
|
||||
}
|
||||
videoFFmpeg.Start(_currentOutPath);
|
||||
videoFFmpeg.Start(CurrentOutPath);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
@@ -573,7 +524,7 @@ namespace ADOFAIRenderer.Core
|
||||
{
|
||||
var firstKey = ImageQueue.Keys[0];
|
||||
|
||||
if (firstKey > passCount && !IsCapturing)
|
||||
if (firstKey > passCount && Instance.stage < RenderStage.Render)
|
||||
{
|
||||
passCount = firstKey;
|
||||
}
|
||||
@@ -657,7 +608,7 @@ namespace ADOFAIRenderer.Core
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Error($"FFmpeg 렌더링 스레드 오류: {ex}");
|
||||
Melon<RenderMod>.Logger.Error($"FFmpeg rendering thread error: {ex}");
|
||||
videoFFmpeg.Dispose();
|
||||
lock (_allVideoFFmpegInstances)
|
||||
{
|
||||
@@ -701,22 +652,22 @@ namespace ADOFAIRenderer.Core
|
||||
if (success) StartCoroutine(StopRenderCoroutine());
|
||||
else
|
||||
{
|
||||
Melon<RenderMod>.Logger.Error("렌더링이 실패했거나 취소되었습니다. 상태 및 리소스를 안전하게 청소합니다.");
|
||||
Melon<RenderMod>.Logger.Error("Rendering failed or was cancelled. Safely cleaning up state and resources.");
|
||||
|
||||
// 1. 렌더링 백그라운드 스레드가 큐에 유입된 잔여 프레임 처리를 마무리하고 종료될 때까지 대기
|
||||
// 1. Wait for render background thread to finish processing remaining queued frames and terminate
|
||||
if (RenderThread is { IsAlive: true })
|
||||
{
|
||||
RenderThread.Join(2000);
|
||||
}
|
||||
|
||||
// 2. 스레드 종료 보장 후, 안전하게 FFmpeg 네이티브 인코더 리소스 해제
|
||||
// 2. After ensuring thread termination, safely release FFmpeg native encoder resources
|
||||
if (_activeVideoFFmpeg != null)
|
||||
{
|
||||
_activeVideoFFmpeg.Dispose();
|
||||
_activeVideoFFmpeg = null;
|
||||
}
|
||||
|
||||
// 3. 메모리 누수 방지: 큐에 렌트된 임시 프레임 버퍼 풀 반환 및 클리어
|
||||
// 3. Memory leak prevention: Return rented temporary frame buffer pool in queue and clear
|
||||
lock (ImageQueue)
|
||||
{
|
||||
foreach (var kvp in ImageQueue.Values)
|
||||
@@ -735,14 +686,14 @@ namespace ADOFAIRenderer.Core
|
||||
|
||||
_frameReader?.Dispose();
|
||||
|
||||
// 디버그 모니터 UI 정리
|
||||
// Clean up Debug Monitor UI
|
||||
if (_debugMonitorObj)
|
||||
{
|
||||
Destroy(_debugMonitorObj);
|
||||
_debugMonitorObj = null;
|
||||
}
|
||||
|
||||
// 4. 인게임 카메라 및 렌더링용 RTCam 상태 안전 원복
|
||||
// 4. Safely restore in-game camera and RTCam state for rendering
|
||||
if (scrCamera.instance)
|
||||
{
|
||||
scrCamera.instance.forceRTCam = false;
|
||||
@@ -752,37 +703,40 @@ namespace ADOFAIRenderer.Core
|
||||
if (hook) Destroy(hook);
|
||||
}
|
||||
|
||||
// 5. 인게임 실패 방지 플래그 원상 복구
|
||||
// 5. Restore in-game no-fail flags
|
||||
if (scrController.instance)
|
||||
{
|
||||
scrController.instance.noFail = false;
|
||||
scrController.instance.noFailInfiniteMargin = false;
|
||||
}
|
||||
|
||||
// 6. 취소된 미완성 임시 동영상 파일 즉각 삭제
|
||||
if (!string.IsNullOrEmpty(_currentOutPath) && File.Exists(_currentOutPath))
|
||||
// 6. Immediately delete cancelled incomplete temporary video file
|
||||
if (!string.IsNullOrEmpty(CurrentOutPath) && File.Exists(CurrentOutPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(_currentOutPath);
|
||||
Melon<RenderMod>.Logger.Msg($"[File Cleanup] 미완성 동영상 삭제 완료: {_currentOutPath}");
|
||||
File.Delete(CurrentOutPath);
|
||||
Melon<RenderMod>.Logger.Msg($"[File Cleanup] Deleted incomplete video: {CurrentOutPath}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Error($"[File Cleanup] 임시 동영상 삭제 실패: {ex.Message}");
|
||||
Melon<RenderMod>.Logger.Error($"[File Cleanup] Failed to delete temporary video: {ex.Message}");
|
||||
}
|
||||
_currentOutPath = null;
|
||||
CurrentOutPath = null;
|
||||
}
|
||||
|
||||
var result = BuildResult(false,
|
||||
IsRenderCancelled ? "Rendering was cancelled by user." : "Rendering failed.");
|
||||
TriggerOnComplete(result);
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerator StopRenderCoroutine()
|
||||
{
|
||||
yield return new WaitUntil(() => endSuccessful);
|
||||
var fileString = !string.IsNullOrEmpty(_currentOutPath) ? $"'{Path.GetFileName(_currentOutPath)}'이(가)" : "동영상이";
|
||||
|
||||
var renderFolder = Path.Combine(MelonEnvironment.GameRootDirectory, "Render");
|
||||
OSNotification.Show("렌더링 완료!", $"{fileString} 성공적으로 저장되었습니다.", renderFolder);
|
||||
var result = BuildResult(success: true);
|
||||
TriggerOnComplete(result);
|
||||
|
||||
EndRender();
|
||||
}
|
||||
@@ -829,13 +783,38 @@ namespace ADOFAIRenderer.Core
|
||||
Cursor.visible = true;
|
||||
}
|
||||
|
||||
private static readonly Dictionary<Codec, AVCodecID> AudioCodecMatch = new()
|
||||
private RenderResult BuildResult(bool success, string? errorMessage = null)
|
||||
{
|
||||
[Codec.H264] = AVCodecID.AV_CODEC_ID_AAC,
|
||||
[Codec.H265] = AVCodecID.AV_CODEC_ID_AAC,
|
||||
[Codec.VP9] = AVCodecID.AV_CODEC_ID_OPUS,
|
||||
[Codec.AV1] = AVCodecID.AV_CODEC_ID_OPUS,
|
||||
[Codec.VVC] = AVCodecID.AV_CODEC_ID_OPUS
|
||||
};
|
||||
_renderStopwatch.Stop();
|
||||
var effectiveFps = Math.Min(Configuration.VideoFPS, Configuration.RenderFPS);
|
||||
var sent = Interlocked.Read(ref framesSent);
|
||||
|
||||
return success
|
||||
? RenderResult.CreateSuccess(CurrentOutPath ?? string.Empty, sent, effectiveFps, _renderStopwatch.Elapsed)
|
||||
: RenderResult.CreateFailure(errorMessage, sent, effectiveFps, _renderStopwatch.Elapsed);
|
||||
}
|
||||
|
||||
private static void TriggerOnComplete(RenderResult result)
|
||||
{
|
||||
try
|
||||
{
|
||||
RenderContext.OnComplete.Invoke(result);
|
||||
}
|
||||
catch (Exception err)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Error($"OnComplete callback caused error: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void TriggerOnLastUpdate()
|
||||
{
|
||||
try
|
||||
{
|
||||
RenderContext.OnLastUpdate.Invoke();
|
||||
}
|
||||
catch (Exception err)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Error($"OnLastUpdate callback caused error: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
-28
@@ -1,11 +1,11 @@
|
||||
namespace ADOFAIRenderer.Data
|
||||
namespace ADOFAIRenderer.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for double input validation limits.
|
||||
/// Multiple limits can be composed on a single input field — all are checked in order.
|
||||
/// </summary>
|
||||
internal abstract class DoubleLimit
|
||||
{
|
||||
/// <summary>
|
||||
/// Base class for double input validation limits.
|
||||
/// Multiple limits can be composed on a single input field — all are checked in order.
|
||||
/// </summary>
|
||||
internal abstract class DoubleLimit
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates the given double value against this limit.
|
||||
/// </summary>
|
||||
@@ -16,13 +16,13 @@ namespace ADOFAIRenderer.Data
|
||||
/// </param>
|
||||
/// <returns>true if the value satisfies this limit; false otherwise.</returns>
|
||||
public abstract bool Validate(double value, out string errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Requires the double value to be strictly positive (> 0).
|
||||
/// </summary>
|
||||
internal class PositiveOnlyDoubleLimit : DoubleLimit
|
||||
{
|
||||
/// <summary>
|
||||
/// Requires the double value to be strictly positive (> 0).
|
||||
/// </summary>
|
||||
internal class PositiveOnlyDoubleLimit : DoubleLimit
|
||||
{
|
||||
public override bool Validate(double value, out string errorMessage)
|
||||
{
|
||||
if (value > 0)
|
||||
@@ -31,19 +31,21 @@ namespace ADOFAIRenderer.Data
|
||||
return true;
|
||||
}
|
||||
|
||||
errorMessage = "값은 0보다 큰 양수여야 합니다.";
|
||||
errorMessage = "Value must be a positive number greater than 0.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Requires the double value to be within an inclusive [Min, Max] range.
|
||||
/// Either bound can be null to represent an unbounded side.
|
||||
/// </summary>
|
||||
internal class RangeDoubleLimit : DoubleLimit
|
||||
{
|
||||
/// <summary>
|
||||
/// Requires the double value to be within an inclusive [Min, Max] range.
|
||||
/// Either bound can be null to represent an unbounded side.
|
||||
/// </summary>
|
||||
internal class RangeDoubleLimit : DoubleLimit
|
||||
{
|
||||
// ReSharper disable MemberCanBePrivate.Global
|
||||
public double? Min { get; }
|
||||
public double? Max { get; }
|
||||
// ReSharper restore MemberCanBePrivate.Global
|
||||
|
||||
/// <param name="min">Inclusive lower bound, or null for no lower bound.</param>
|
||||
/// <param name="max">Inclusive upper bound, or null for no upper bound.</param>
|
||||
@@ -55,24 +57,23 @@ namespace ADOFAIRenderer.Data
|
||||
|
||||
public override bool Validate(double value, out string errorMessage)
|
||||
{
|
||||
if (Min.HasValue && value < Min.Value)
|
||||
if (value < Min)
|
||||
{
|
||||
errorMessage = Max.HasValue
|
||||
? $"값은 {Min.Value}에서 {Max.Value} 사이의 숫자여야 합니다."
|
||||
: $"값은 {Min.Value} 이상의 숫자여야 합니다.";
|
||||
? $"Value must be a number between {Min.Value} and {Max.Value}."
|
||||
: $"Value must be a number greater than or equal to {Min.Value}.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Max.HasValue && value > Max.Value)
|
||||
if (value > Max)
|
||||
{
|
||||
errorMessage = Min.HasValue
|
||||
? $"값은 {Min.Value}에서 {Max.Value} 사이의 숫자여야 합니다."
|
||||
: $"값은 {Max.Value} 이하의 숫자여야 합니다.";
|
||||
? $"Value must be a number between {Min.Value} and {Max.Value}."
|
||||
: $"Value must be a number less than or equal to {Max.Value}.";
|
||||
return false;
|
||||
}
|
||||
|
||||
errorMessage = null!;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using MelonLoader;
|
||||
using MelonLoader.Utils;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace ADOFAIRenderer.Data;
|
||||
|
||||
public class GlobalConfig
|
||||
{
|
||||
public string? OutputDirectory { get; set; }
|
||||
}
|
||||
|
||||
public static class GlobalSettings
|
||||
{
|
||||
private static string SettingsFilePath =>
|
||||
Path.Combine(MelonEnvironment.GameRootDirectory, "UserData", "ADOFAIRenderer", "global_settings.json");
|
||||
|
||||
public static GlobalConfig Current { get; set; } = new();
|
||||
|
||||
public static string DefaultOutputDirectory =>
|
||||
Path.Combine(MelonEnvironment.GameRootDirectory, "Render");
|
||||
|
||||
public static string EffectiveOutputDirectory
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(Current.OutputDirectory) && Directory.Exists(Current.OutputDirectory))
|
||||
{
|
||||
return Current.OutputDirectory;
|
||||
}
|
||||
|
||||
return DefaultOutputDirectory;
|
||||
}
|
||||
}
|
||||
|
||||
static GlobalSettings()
|
||||
{
|
||||
Load();
|
||||
}
|
||||
|
||||
public static void Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(SettingsFilePath))
|
||||
{
|
||||
var json = File.ReadAllText(SettingsFilePath);
|
||||
Current = JsonConvert.DeserializeObject<GlobalConfig>(json) ?? new GlobalConfig();
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Warning($"[GlobalSettings] Failed to load global settings: {ex.Message}");
|
||||
}
|
||||
|
||||
Current = new GlobalConfig();
|
||||
}
|
||||
|
||||
public static void Save()
|
||||
{
|
||||
try
|
||||
{
|
||||
var dir = Path.GetDirectoryName(SettingsFilePath);
|
||||
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
|
||||
var json = JsonConvert.SerializeObject(Current, Formatting.Indented);
|
||||
File.WriteAllText(SettingsFilePath, json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Warning($"[GlobalSettings] Failed to save global settings: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,10 @@ using System.Linq;
|
||||
using MelonLoader;
|
||||
using UnityEngine.UIElements;
|
||||
|
||||
namespace ADOFAIRenderer.Data.Input
|
||||
namespace ADOFAIRenderer.Data.Input;
|
||||
|
||||
internal class DoubleInputValidator
|
||||
{
|
||||
internal class DoubleInputValidator
|
||||
{
|
||||
private struct FieldEntry
|
||||
{
|
||||
public List<DoubleLimit> Limits;
|
||||
@@ -53,7 +53,7 @@ namespace ADOFAIRenderer.Data.Input
|
||||
if (field == null)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Warning(
|
||||
$"DoubleField '{fieldName}'을(를) 찾을 수 없습니다. 유효성 검사를 건너뜁니다.");
|
||||
$"DoubleField '{fieldName}' not found. Skipping validation.");
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -73,5 +73,4 @@ namespace ADOFAIRenderer.Data.Input
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,20 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using MelonLoader;
|
||||
using UnityEngine.UIElements;
|
||||
|
||||
namespace ADOFAIRenderer.Data.Input
|
||||
namespace ADOFAIRenderer.Data.Input;
|
||||
|
||||
/// <summary>
|
||||
/// Binds a UI Toolkit <see cref="DropdownField"/> to an enum type.
|
||||
/// Populates choices from enum names and provides typed access to the selected value.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The enum type to bind.</typeparam>
|
||||
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
|
||||
internal class EnumDropdownBinder<T> where T : struct, Enum
|
||||
{
|
||||
/// <summary>
|
||||
/// Binds a UI Toolkit <see cref="DropdownField"/> to an enum type.
|
||||
/// Populates choices from enum names and provides typed access to the selected value.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The enum type to bind.</typeparam>
|
||||
internal class EnumDropdownBinder<T> where T : struct, Enum
|
||||
{
|
||||
private readonly DropdownField? _dropdown;
|
||||
private readonly List<T> _values;
|
||||
private Action<T>? _onValueChanged;
|
||||
@@ -39,7 +41,7 @@ namespace ADOFAIRenderer.Data.Input
|
||||
if (_dropdown == null)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Error(
|
||||
$"DropdownField '{fieldName}'을(를) 찾을 수 없습니다.");
|
||||
$"DropdownField '{fieldName}' not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -82,5 +84,4 @@ namespace ADOFAIRenderer.Data.Input
|
||||
_dropdown.index = idx;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,10 @@ using System.Linq;
|
||||
using MelonLoader;
|
||||
using UnityEngine.UIElements;
|
||||
|
||||
namespace ADOFAIRenderer.Data.Input
|
||||
namespace ADOFAIRenderer.Data.Input;
|
||||
|
||||
internal class IntegerInputValidator
|
||||
{
|
||||
internal class IntegerInputValidator
|
||||
{
|
||||
private struct FieldEntry
|
||||
{
|
||||
public List<IntegerLimit> Limits;
|
||||
@@ -53,7 +53,7 @@ namespace ADOFAIRenderer.Data.Input
|
||||
if (field == null)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Warning(
|
||||
$"IntegerField '{fieldName}'을(를) 찾을 수 없습니다. 유효성 검사를 건너뜁니다.");
|
||||
$"IntegerField '{fieldName}' not found. Skipping validation.");
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -73,5 +73,4 @@ namespace ADOFAIRenderer.Data.Input
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
-28
@@ -1,11 +1,11 @@
|
||||
namespace ADOFAIRenderer.Data
|
||||
namespace ADOFAIRenderer.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for integer input validation limits.
|
||||
/// Multiple limits can be composed on a single input field — all are checked in order.
|
||||
/// </summary>
|
||||
internal abstract class IntegerLimit
|
||||
{
|
||||
/// <summary>
|
||||
/// Base class for integer input validation limits.
|
||||
/// Multiple limits can be composed on a single input field — all are checked in order.
|
||||
/// </summary>
|
||||
internal abstract class IntegerLimit
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates the given integer value against this limit.
|
||||
/// </summary>
|
||||
@@ -16,13 +16,13 @@ namespace ADOFAIRenderer.Data
|
||||
/// </param>
|
||||
/// <returns>true if the value satisfies this limit; false otherwise.</returns>
|
||||
public abstract bool Validate(int value, out string errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Requires the integer value to be strictly positive (> 0).
|
||||
/// </summary>
|
||||
internal class PositiveOnlyIntegerLimit : IntegerLimit
|
||||
{
|
||||
/// <summary>
|
||||
/// Requires the integer value to be strictly positive (> 0).
|
||||
/// </summary>
|
||||
internal class PositiveOnlyIntegerLimit : IntegerLimit
|
||||
{
|
||||
public override bool Validate(int value, out string errorMessage)
|
||||
{
|
||||
if (value > 0)
|
||||
@@ -31,19 +31,21 @@ namespace ADOFAIRenderer.Data
|
||||
return true;
|
||||
}
|
||||
|
||||
errorMessage = "값은 0보다 큰 양의 정수여야 합니다.";
|
||||
errorMessage = "Value must be a positive integer greater than 0.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Requires the integer value to be within an inclusive [Min, Max] range.
|
||||
/// Either bound can be null to represent an unbounded side.
|
||||
/// </summary>
|
||||
internal class RangeIntegerLimit : IntegerLimit
|
||||
{
|
||||
/// <summary>
|
||||
/// Requires the integer value to be within an inclusive [Min, Max] range.
|
||||
/// Either bound can be null to represent an unbounded side.
|
||||
/// </summary>
|
||||
internal class RangeIntegerLimit : IntegerLimit
|
||||
{
|
||||
// ReSharper disable MemberCanBePrivate.Global
|
||||
public int? Min { get; }
|
||||
public int? Max { get; }
|
||||
// ReSharper restore MemberCanBePrivate.Global
|
||||
|
||||
/// <param name="min">Inclusive lower bound, or null for no lower bound.</param>
|
||||
/// <param name="max">Inclusive upper bound, or null for no upper bound.</param>
|
||||
@@ -55,24 +57,23 @@ namespace ADOFAIRenderer.Data
|
||||
|
||||
public override bool Validate(int value, out string errorMessage)
|
||||
{
|
||||
if (Min.HasValue && value < Min.Value)
|
||||
if (value < Min)
|
||||
{
|
||||
errorMessage = Max.HasValue
|
||||
? $"값은 {Min.Value}에서 {Max.Value} 사이의 정수여야 합니다."
|
||||
: $"값은 {Min.Value} 이상의 정수여야 합니다.";
|
||||
? $"Value must be an integer between {Min.Value} and {Max.Value}."
|
||||
: $"Value must be an integer greater than or equal to {Min.Value}.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Max.HasValue && value > Max.Value)
|
||||
if (value > Max)
|
||||
{
|
||||
errorMessage = Min.HasValue
|
||||
? $"값은 {Min.Value}에서 {Max.Value} 사이의 정수여야 합니다."
|
||||
: $"값은 {Max.Value} 이하의 정수여야 합니다.";
|
||||
? $"Value must be an integer between {Min.Value} and {Max.Value}."
|
||||
: $"Value must be an integer less than or equal to {Max.Value}.";
|
||||
return false;
|
||||
}
|
||||
|
||||
errorMessage = null!;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-10
@@ -7,16 +7,16 @@ using MelonLoader.Utils;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace ADOFAIRenderer.Data
|
||||
namespace ADOFAIRenderer.Data;
|
||||
|
||||
internal class RenderPreset
|
||||
{
|
||||
internal class RenderPreset
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public RenderConfig Config { get; set; } = null!;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class PresetManager
|
||||
{
|
||||
internal static class PresetManager
|
||||
{
|
||||
private static string GetPresetsDirectory()
|
||||
{
|
||||
return Path.Combine(MelonEnvironment.GameRootDirectory, "UserData", "ADOFAIRenderer", "Presets");
|
||||
@@ -31,7 +31,7 @@ namespace ADOFAIRenderer.Data
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
throw new ArgumentException("프리셋 이름은 비어 있을 수 없습니다.");
|
||||
throw new ArgumentException("Preset name cannot be empty.");
|
||||
}
|
||||
|
||||
string dir = GetPresetsDirectory();
|
||||
@@ -154,13 +154,13 @@ namespace ADOFAIRenderer.Data
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Warning($"프리셋 파일 '{Path.GetFileName(file)}' 로드 중 오류 발생: {ex.Message}");
|
||||
MelonLogger.Warning($"Error loading preset file '{Path.GetFileName(file)}': {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Error($"프리셋 목록을 불러오는 중 오류 발생: {ex.Message}");
|
||||
MelonLogger.Error($"Error retrieving preset list: {ex.Message}");
|
||||
}
|
||||
|
||||
return presets;
|
||||
@@ -186,5 +186,4 @@ namespace ADOFAIRenderer.Data
|
||||
File.Delete(filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
-19
@@ -1,11 +1,12 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using ADOFAIRenderer.Util.FFmpeg;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ADOFAIRenderer.Data
|
||||
namespace ADOFAIRenderer.Data;
|
||||
|
||||
[SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")]
|
||||
public class RenderConfig
|
||||
{
|
||||
[SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")]
|
||||
public class RenderConfig
|
||||
{
|
||||
public double RenderFPS { get; set; }
|
||||
public double VideoFPS { get; set; }
|
||||
|
||||
@@ -16,21 +17,25 @@ namespace ADOFAIRenderer.Data
|
||||
public AudioEncodingConfig AudioConfig { get; set; } = new();
|
||||
public AdvancedConfig AdvancedConfig { get; set; } = new();
|
||||
|
||||
public double DelayAfterEnd { get; set; }
|
||||
public double DelayAfterEnd { get; set; } = 15;
|
||||
public bool ShowJudgementText { get; set; } = false;
|
||||
}
|
||||
}
|
||||
|
||||
[SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")]
|
||||
public class AdvancedConfig
|
||||
{
|
||||
[SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")]
|
||||
public class AdvancedConfig
|
||||
{
|
||||
public int? RandomSeed { get; set; } = null;
|
||||
public bool ProcessAudio { get; set; } = true;
|
||||
public bool NonTile { get; set; } = false;
|
||||
public string FileExtension { get; set; } = "mkv";
|
||||
}
|
||||
|
||||
public class VideoEncodingConfig
|
||||
{
|
||||
public Color? firePlanetColor = null;
|
||||
public Color? icePlanetColor = null;
|
||||
public Color? windPlanetColor = null;
|
||||
}
|
||||
|
||||
public class VideoEncodingConfig
|
||||
{
|
||||
public Codec Codec { get; set; } = Codec.H264;
|
||||
public RateControlMode RateControlMode { get; set; } = RateControlMode.CBR;
|
||||
|
||||
@@ -40,11 +45,11 @@ namespace ADOFAIRenderer.Data
|
||||
public int QP { get; set; } = 18;
|
||||
public double KeyframeInterval { get; set; } = 2.0;
|
||||
public bool ForceSoftwareEncoder { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
public class AudioEncodingConfig
|
||||
{
|
||||
private int _audioSampleRate = 44100;
|
||||
public class AudioEncodingConfig
|
||||
{
|
||||
private int _audioSampleRate = 48000;
|
||||
private int _audioChannels = 2;
|
||||
private int _audioBitrate = 256000;
|
||||
|
||||
@@ -52,8 +57,8 @@ namespace ADOFAIRenderer.Data
|
||||
|
||||
public int AudioSampleRate
|
||||
{
|
||||
get => _audioSampleRate <= 0 ? 44100 : _audioSampleRate;
|
||||
set => _audioSampleRate = value <= 0 ? 44100 : value;
|
||||
get => _audioSampleRate <= 0 ? 48000 : _audioSampleRate;
|
||||
set => _audioSampleRate = value <= 0 ? 48000 : value;
|
||||
}
|
||||
|
||||
public int AudioChannels
|
||||
@@ -67,5 +72,4 @@ namespace ADOFAIRenderer.Data
|
||||
get => _audioBitrate <= 0 ? 256000 : _audioBitrate;
|
||||
set => _audioBitrate = value <= 0 ? 256000 : value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,674 @@
|
||||
MIT License
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (c) 2026 ADOFAI Editor Forum
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
Preamble
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"ui.render_config.window_title": "Render Config",
|
||||
"ui.render_config.render_fps": "Render FPS",
|
||||
"ui.render_config.video_fps": "Video FPS",
|
||||
"ui.render_config.render_width": "Render Width",
|
||||
"ui.render_config.render_height": "Render Height",
|
||||
"ui.render_config.delay_after_end": "Delay After End (s)",
|
||||
"ui.render_config.show_judgement": "Show Judgement Text",
|
||||
"ui.render_config.video_encoding": "Video Encoding Config",
|
||||
"ui.render_config.audio_encoding": "Audio Encoding Config",
|
||||
"ui.render_config.global_settings": "Global Settings",
|
||||
"ui.render_config.advanced_settings": "Advanced Settings",
|
||||
"ui.render_config.go_render": "Go Render",
|
||||
"ui.render_config.codec": "Codec",
|
||||
"ui.render_config.rate_control": "Rate Control Mode",
|
||||
"ui.render_config.bitrate": "Bitrate (kbps)",
|
||||
"ui.render_config.max_bitrate": "Max Bitrate (kbps)",
|
||||
"ui.render_config.qp": "Quality (QP)",
|
||||
"ui.render_config.keyframe_interval": "Keyframe Interval (s)",
|
||||
"ui.render_config.force_software": "Force Software Encoder",
|
||||
"ui.render_config.render_audio": "Render Audio",
|
||||
"ui.render_config.sample_rate": "Sample Rate (Hz)",
|
||||
"ui.render_config.channels": "Audio Channels",
|
||||
"ui.render_config.audio_bitrate": "Audio Bitrate (kbps)",
|
||||
"ui.render_config.output_dir": "Output Directory",
|
||||
"ui.render_config.browse": "Browse...",
|
||||
"ui.render_config.reset_default": "Reset to Default",
|
||||
"ui.render_config.file_extension": "File Extension",
|
||||
"ui.render_config.process_audio": "Process Audio",
|
||||
"ui.render_config.non_tile": "Non-Tile Mode",
|
||||
"ui.render_config.random_seed": "Random Seed (Optional)",
|
||||
"ui.render_config.validation_title": "Invalid Settings",
|
||||
"ui.render_config.validation_error": "Please check your input values:\n- Width and Height must be integers > 0\n- Render FPS and Video FPS must be > 0\n- Delay After End must be >= 0",
|
||||
"ui.render_config.invalid_path": "Output directory path is invalid:\n{0}",
|
||||
"ui.render_config.expired": "(Expired)"
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"ui.render_config.window_title": "렌더 설정",
|
||||
"ui.render_config.render_fps": "렌더링 FPS",
|
||||
"ui.render_config.video_fps": "영상 FPS",
|
||||
"ui.render_config.render_width": "렌더링 너비",
|
||||
"ui.render_config.render_height": "렌더링 높이",
|
||||
"ui.render_config.delay_after_end": "종료 후 대기 시간 (초)",
|
||||
"ui.render_config.show_judgement": "판정 텍스트 표시",
|
||||
"ui.render_config.video_encoding": "Video Encoding Config",
|
||||
"ui.render_config.audio_encoding": "Audio Encoding Config",
|
||||
"ui.render_config.global_settings": "Global Settings",
|
||||
"ui.render_config.advanced_settings": "Advanced Settings",
|
||||
"ui.render_config.go_render": "렌더 시작",
|
||||
"ui.render_config.codec": "코덱",
|
||||
"ui.render_config.rate_control": "비트레이트 제어 모드",
|
||||
"ui.render_config.bitrate": "비트레이트 (kbps)",
|
||||
"ui.render_config.max_bitrate": "최대 비트레이트 (kbps)",
|
||||
"ui.render_config.qp": "품질 (QP)",
|
||||
"ui.render_config.keyframe_interval": "키프레임 간격 (초)",
|
||||
"ui.render_config.force_software": "강제 소프트웨어 인코더",
|
||||
"ui.render_config.render_audio": "오디오 렌더링",
|
||||
"ui.render_config.sample_rate": "샘플레이트 (Hz)",
|
||||
"ui.render_config.channels": "오디오 채널",
|
||||
"ui.render_config.audio_bitrate": "오디오 비트레이트 (kbps)",
|
||||
"ui.render_config.output_dir": "출력 디렉토리",
|
||||
"ui.render_config.browse": "찾아보기...",
|
||||
"ui.render_config.reset_default": "기본값으로 초기화",
|
||||
"ui.render_config.file_extension": "파일 확장자",
|
||||
"ui.render_config.process_audio": "오디오 처리",
|
||||
"ui.render_config.non_tile": "논타일 모드",
|
||||
"ui.render_config.random_seed": "랜덤 시드 (선택 사항)",
|
||||
"ui.render_config.validation_title": "설정 오류",
|
||||
"ui.render_config.validation_error": "입력값을 확인해 주세요:\n- 너비와 높이는 0보다 큰 정수여야 합니다.\n- 렌더 FPS와 영상 FPS는 0보다 커야 합니다.\n- 종료 후 대기 시간은 0 이상이어야 합니다.",
|
||||
"ui.render_config.invalid_path": "출력 디렉토리 경로가 올바르지 않습니다:\n{0}",
|
||||
"ui.render_config.expired": "(만료됨)"
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using MelonLoader;
|
||||
using Newtonsoft.Json;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ADOFAIRenderer.Localization;
|
||||
|
||||
/// <summary>
|
||||
/// Provides access to localized strings and handles language synchronization with the game.
|
||||
/// </summary>
|
||||
internal static class LocalizationManager
|
||||
{
|
||||
private static readonly Dictionary<string, Dictionary<string, string>> Locales = new(StringComparer.OrdinalIgnoreCase);
|
||||
private static readonly string FallbackLocaleCode = "en";
|
||||
|
||||
/// <summary>
|
||||
/// Event triggered when the active language changes.
|
||||
/// </summary>
|
||||
public static event Action<SystemLanguage>? OnLanguageChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Currently active SystemLanguage.
|
||||
/// </summary>
|
||||
public static SystemLanguage CurrentLanguage { get; private set; } = SystemLanguage.English;
|
||||
|
||||
/// <summary>
|
||||
/// Currently active locale code (e.g. "en", "ko").
|
||||
/// </summary>
|
||||
public static string CurrentLocaleCode { get; private set; } = "en";
|
||||
|
||||
private static bool _initialized;
|
||||
|
||||
static LocalizationManager()
|
||||
{
|
||||
Initialize();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the localization system and loads embedded language dictionaries.
|
||||
/// </summary>
|
||||
public static void Initialize()
|
||||
{
|
||||
if (_initialized) return;
|
||||
_initialized = true;
|
||||
|
||||
LoadEmbeddedLocales();
|
||||
|
||||
// Detect initial language from game settings
|
||||
var initialLang = DetectGameLanguage();
|
||||
SetLanguage(initialLang, notify: false);
|
||||
|
||||
LogMsg($"[Localization] Initialized. Active language: {CurrentLanguage} ({CurrentLocaleCode})");
|
||||
}
|
||||
|
||||
private static void LogMsg(string msg)
|
||||
{
|
||||
try { Melon<RenderMod>.Logger.Msg(msg); }
|
||||
catch { Console.WriteLine(msg); }
|
||||
}
|
||||
|
||||
private static void LogError(string msg)
|
||||
{
|
||||
try { Melon<RenderMod>.Logger.Error(msg); }
|
||||
catch { Console.Error.WriteLine(msg); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads all *.json locale files embedded in this assembly.
|
||||
/// </summary>
|
||||
private static void LoadEmbeddedLocales()
|
||||
{
|
||||
var assembly = typeof(LocalizationManager).Assembly;
|
||||
var resourceNames = assembly.GetManifestResourceNames();
|
||||
|
||||
foreach (var resourceName in resourceNames)
|
||||
{
|
||||
if (!resourceName.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) continue;
|
||||
if (!resourceName.Contains("Localization.Locale")) continue;
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = assembly.GetManifestResourceStream(resourceName);
|
||||
if (stream == null) continue;
|
||||
|
||||
using var reader = new StreamReader(stream);
|
||||
var json = reader.ReadToEnd();
|
||||
var dict = JsonConvert.DeserializeObject<Dictionary<string, string>>(json);
|
||||
|
||||
if (dict != null)
|
||||
{
|
||||
// Extract locale code from resource name: e.g. "ADOFAIRenderer.Localization.Locale.ko.json" -> "ko"
|
||||
var parts = resourceName.Split('.');
|
||||
if (parts.Length >= 2)
|
||||
{
|
||||
var code = parts[^2].ToLowerInvariant();
|
||||
Locales[code] = dict;
|
||||
LogMsg($"[Localization] Loaded locale '{code}' ({dict.Count} keys).");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogError($"[Localization] Failed to load locale resource '{resourceName}': {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the active language and notifies subscribers if changed.
|
||||
/// </summary>
|
||||
public static void SetLanguage(SystemLanguage language, bool notify = true)
|
||||
{
|
||||
var localeCode = LanguageToLocaleCode(language);
|
||||
if (!Locales.ContainsKey(localeCode))
|
||||
{
|
||||
// Fallback to English if translation for this language does not exist
|
||||
localeCode = FallbackLocaleCode;
|
||||
}
|
||||
|
||||
var isChanged = language != CurrentLanguage || localeCode != CurrentLocaleCode;
|
||||
|
||||
CurrentLanguage = language;
|
||||
CurrentLocaleCode = localeCode;
|
||||
|
||||
if (isChanged && notify)
|
||||
{
|
||||
LogMsg($"[Localization] Language changed to: {CurrentLanguage} ({CurrentLocaleCode})");
|
||||
try
|
||||
{
|
||||
OnLanguageChanged?.Invoke(CurrentLanguage);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogError($"[Localization] Error during OnLanguageChanged notification: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a localized string for the specified key.
|
||||
/// </summary>
|
||||
public static string Get(string key)
|
||||
{
|
||||
if (string.IsNullOrEmpty(key)) return string.Empty;
|
||||
if (!_initialized) Initialize();
|
||||
|
||||
// 1. Try currently active locale
|
||||
if (Locales.TryGetValue(CurrentLocaleCode, out var activeDict) && activeDict.TryGetValue(key, out var val))
|
||||
{
|
||||
return val;
|
||||
}
|
||||
|
||||
// 2. Fallback to English locale
|
||||
if (CurrentLocaleCode != FallbackLocaleCode &&
|
||||
Locales.TryGetValue(FallbackLocaleCode, out var fallbackDict) &&
|
||||
fallbackDict.TryGetValue(key, out var fallbackVal))
|
||||
{
|
||||
return fallbackVal;
|
||||
}
|
||||
|
||||
// 3. Fallback to raw key if missing
|
||||
return key;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a formatted localized string.
|
||||
/// </summary>
|
||||
public static string Get(string key, params object[] args)
|
||||
{
|
||||
var template = Get(key);
|
||||
if (args.Length == 0) return template;
|
||||
|
||||
try
|
||||
{
|
||||
return string.Format(template, args);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return template;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps Unity's SystemLanguage enum to an internal locale code.
|
||||
/// </summary>
|
||||
private static string LanguageToLocaleCode(SystemLanguage language)
|
||||
{
|
||||
return language switch
|
||||
{
|
||||
SystemLanguage.Korean => "ko",
|
||||
_ => "en"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to read the game's current language from RDString or Persistence.
|
||||
/// </summary>
|
||||
private static SystemLanguage DetectGameLanguage()
|
||||
{
|
||||
try
|
||||
{
|
||||
// First attempt: RDString.language
|
||||
return RDString.language;
|
||||
}
|
||||
catch
|
||||
{
|
||||
try
|
||||
{
|
||||
// Fallback attempt: Persistence.language
|
||||
return Persistence.language;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return SystemLanguage.English;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convenient shorthand alias for <see cref="LocalizationManager"/>.
|
||||
/// </summary>
|
||||
// ReSharper disable once InconsistentNaming
|
||||
internal static class I18n
|
||||
{
|
||||
/// <inheritdoc cref="LocalizationManager.Get(string)"/>
|
||||
public static string Get(string key) => LocalizationManager.Get(key);
|
||||
|
||||
/// <inheritdoc cref="LocalizationManager.Get(string, object[])"/>
|
||||
public static string Get(string key, params object[] args) => LocalizationManager.Get(key, args);
|
||||
|
||||
/// <inheritdoc cref="LocalizationManager.CurrentLanguage"/>
|
||||
public static SystemLanguage CurrentLanguage => LocalizationManager.CurrentLanguage;
|
||||
|
||||
/// <inheritdoc cref="LocalizationManager.CurrentLocaleCode"/>
|
||||
public static string CurrentLocaleCode => LocalizationManager.CurrentLocaleCode;
|
||||
|
||||
/// <inheritdoc cref="LocalizationManager.OnLanguageChanged"/>
|
||||
public static event Action<SystemLanguage>? OnLanguageChanged
|
||||
{
|
||||
add => LocalizationManager.OnLanguageChanged += value;
|
||||
remove => LocalizationManager.OnLanguageChanged -= value;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,20 @@
|
||||
using ADOFAIRenderer.Core;
|
||||
using HarmonyLib;
|
||||
|
||||
namespace ADOFAIRenderer.Patch.Engine
|
||||
namespace ADOFAIRenderer.Patch.Engine;
|
||||
|
||||
[HarmonyPatch(typeof(AsyncInputUtils), "UpdateOffsetTime")]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public static class AsyncInputPatches
|
||||
{
|
||||
[HarmonyPatch(typeof(AsyncInputUtils), "UpdateOffsetTime")]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public static class AsyncInputPatches
|
||||
{
|
||||
[HarmonyPrefix]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public static bool Prefix()
|
||||
{
|
||||
if (!RenderManager.IsActive) return true;
|
||||
// Force offsetTick to match instantly without 1/100 damping division when rendering
|
||||
AsyncInputManager.offsetTick = AsyncInputManager.currFrameTick - (ulong)(scrConductor.instance.dspTime * 10000000.0);
|
||||
AsyncInputManager.offsetTick = AsyncInputManager.currFrameTick - (long)(scrConductor.instance.dspTime * 10000000.0);
|
||||
AsyncInputManager.offsetTickUpdated = true;
|
||||
return false; // Skip the original damped offset calculation
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,11 @@ using HarmonyLib;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Audio;
|
||||
|
||||
namespace ADOFAIRenderer.Patch.Engine
|
||||
namespace ADOFAIRenderer.Patch.Engine;
|
||||
|
||||
[HarmonyPatch(typeof(AudioManager), "_Play")]
|
||||
public static class AudioManagerPlayPatch
|
||||
{
|
||||
[HarmonyPatch(typeof(AudioManager), "_Play")]
|
||||
public static class AudioManagerPlayPatch
|
||||
{
|
||||
[HarmonyPrefix]
|
||||
public static bool Prefix(string snd, double time, AudioMixerGroup group, float volume, int priority)
|
||||
{
|
||||
@@ -33,11 +33,11 @@ namespace ADOFAIRenderer.Patch.Engine
|
||||
RenderManager.Instance.AudioMixer?.RegisterSoundEvent(__result, snd, relativeStartTime, -1.0, volume, 1.0f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(scrConductor), "PlayWithEndTime")]
|
||||
public static class PlayWithEndTimePatch
|
||||
{
|
||||
[HarmonyPatch(typeof(scrConductor), "PlayWithEndTime")]
|
||||
public static class PlayWithEndTimePatch
|
||||
{
|
||||
[HarmonyPrefix]
|
||||
public static bool Prefix(string snd, double time, double endTime, float volume, int priority)
|
||||
{
|
||||
@@ -59,11 +59,11 @@ namespace ADOFAIRenderer.Patch.Engine
|
||||
}
|
||||
return true; // Let the original method execute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(AudioSource), "pitch", MethodType.Setter)]
|
||||
public static class AudioSourcePitchPatch
|
||||
{
|
||||
[HarmonyPatch(typeof(AudioSource), "pitch", MethodType.Setter)]
|
||||
public static class AudioSourcePitchPatch
|
||||
{
|
||||
[HarmonyPostfix]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public static void Postfix(AudioSource __instance, float value)
|
||||
@@ -77,11 +77,11 @@ namespace ADOFAIRenderer.Patch.Engine
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(AudioSource), "SetScheduledEndTime", typeof(double))]
|
||||
public static class AudioSourceSetScheduledEndTimePatch
|
||||
{
|
||||
[HarmonyPatch(typeof(AudioSource), "SetScheduledEndTime", typeof(double))]
|
||||
public static class AudioSourceSetScheduledEndTimePatch
|
||||
{
|
||||
[HarmonyPostfix]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public static void Postfix(AudioSource __instance, double time)
|
||||
@@ -95,11 +95,11 @@ namespace ADOFAIRenderer.Patch.Engine
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(AudioSource), "Stop", new Type[0])]
|
||||
public static class AudioSourceStopPatch
|
||||
{
|
||||
[HarmonyPatch(typeof(AudioSource), "Stop", new Type[0])]
|
||||
public static class AudioSourceStopPatch
|
||||
{
|
||||
[HarmonyPostfix]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public static void Postfix(AudioSource __instance)
|
||||
@@ -109,5 +109,4 @@ namespace ADOFAIRenderer.Patch.Engine
|
||||
RenderManager.Instance.AudioMixer?.RemoveSourceMapping(__instance);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,13 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using ADOFAIRenderer.Core;
|
||||
using ADOFAIRenderer.Util;
|
||||
using ADOFAIRenderer.Util.Audio;
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ADOFAIRenderer.Patch.Engine
|
||||
namespace ADOFAIRenderer.Patch.Engine;
|
||||
|
||||
public static class RenderEnvironmentPatches
|
||||
{
|
||||
public static class RenderEnvironmentPatches
|
||||
{
|
||||
private const bool RenderPatch = true;
|
||||
private static double TimeSpent => RenderManager.Instance.frameCount / RenderManager.Instance.Configuration.RenderFPS;
|
||||
|
||||
[HarmonyPatch(typeof(Time), "time", MethodType.Getter)]
|
||||
@@ -160,9 +158,8 @@ namespace ADOFAIRenderer.Patch.Engine
|
||||
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||
public static void Prefix(ref bool stream)
|
||||
{
|
||||
if (RenderManager.Instance && RenderManager.Instance.OverrideLoading && RenderManager.Instance.Configuration.AdvancedConfig.ProcessAudio)
|
||||
if (RenderManager.Instance && RenderManager.Instance.stage >= RenderStage.Load && RenderManager.Instance.Configuration.AdvancedConfig.ProcessAudio)
|
||||
stream = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,12 @@ using System.Diagnostics.CodeAnalysis;
|
||||
using ADOFAIRenderer.Core;
|
||||
using HarmonyLib;
|
||||
|
||||
namespace ADOFAIRenderer.Patch.Game
|
||||
namespace ADOFAIRenderer.Patch.Game;
|
||||
|
||||
[HarmonyPatch(typeof(EndscreenLanterns))]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public static class EndscreenLanternsPatch
|
||||
{
|
||||
[HarmonyPatch(typeof(EndscreenLanterns))]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public static class EndscreenLanternsPatch
|
||||
{
|
||||
[HarmonyPrefix]
|
||||
[HarmonyPatch("Show")]
|
||||
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||
@@ -70,5 +70,4 @@ namespace ADOFAIRenderer.Patch.Game
|
||||
}
|
||||
chainSequenceField.SetValue(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using ADOFAIRenderer.Localization;
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ADOFAIRenderer.Patch.Game;
|
||||
|
||||
/// <summary>
|
||||
/// Synchronizes mod localization with ADOFAI's in-game language settings.
|
||||
/// </summary>
|
||||
[HarmonyPatch(typeof(RDString))]
|
||||
internal static class LanguageSyncPatch
|
||||
{
|
||||
[HarmonyPatch(nameof(RDString.ChangeLanguage))]
|
||||
[HarmonyPostfix]
|
||||
public static void OnChangeLanguage(SystemLanguage language)
|
||||
{
|
||||
LocalizationManager.SetLanguage(language);
|
||||
}
|
||||
|
||||
[HarmonyPatch(nameof(RDString.Setup))]
|
||||
[HarmonyPostfix]
|
||||
public static void OnSetup()
|
||||
{
|
||||
LocalizationManager.SetLanguage(RDString.language);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
using ADOFAIRenderer.Core;
|
||||
using HarmonyLib;
|
||||
|
||||
namespace ADOFAIRenderer.Patch.Game
|
||||
namespace ADOFAIRenderer.Patch.Game;
|
||||
|
||||
[HarmonyPatch(typeof(Persistence))]
|
||||
public static class PersistencePatch
|
||||
{
|
||||
[HarmonyPatch(typeof(Persistence))]
|
||||
public static class PersistencePatch
|
||||
{
|
||||
[HarmonyPostfix]
|
||||
[HarmonyPatch("skipIntroBehavior", MethodType.Getter)]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
@@ -18,5 +18,4 @@ namespace ADOFAIRenderer.Patch.Game
|
||||
__result = SkipIntroBehavior.Off;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using ADOFAIRenderer.Core;
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ADOFAIRenderer.Patch.Game;
|
||||
|
||||
[HarmonyPatch(typeof(PlanetRenderer))]
|
||||
public static class PlanetRendererPatch
|
||||
{
|
||||
[HarmonyPrefix]
|
||||
[HarmonyPatch("SetColor")]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
private static void OverrideColor(PlanetRenderer __instance, ref PlanetColor planetColor)
|
||||
{
|
||||
if (RenderManager.Instance.stage == RenderStage.None) return;
|
||||
|
||||
var advConf = RenderManager.Instance.Configuration.AdvancedConfig;
|
||||
ApplyColor(ref planetColor, scrController.instance.planetRed, advConf.firePlanetColor);
|
||||
ApplyColor(ref planetColor, scrController.instance.planetBlue, advConf.icePlanetColor);
|
||||
ApplyColor(ref planetColor, scrController.instance.planetGreen, advConf.windPlanetColor);
|
||||
|
||||
return;
|
||||
|
||||
void ApplyColor(ref PlanetColor planetColor, scrPlanet? targetPlanet, Color? customColor)
|
||||
{
|
||||
if (targetPlanet != null && __instance == targetPlanet.planetRenderer && customColor != null)
|
||||
planetColor = new PlanetColor(customColor.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,12 @@ using MelonLoader;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace ADOFAIRenderer.Patch.Game
|
||||
namespace ADOFAIRenderer.Patch.Game;
|
||||
|
||||
[HarmonyPatch(typeof(scnEditor))]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public static class scnEditorPatch
|
||||
{
|
||||
[HarmonyPatch(typeof(scnEditor))]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public static class scnEditorPatch
|
||||
{
|
||||
[HarmonyPostfix]
|
||||
[HarmonyPatch("Start")]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
@@ -18,7 +18,7 @@ namespace ADOFAIRenderer.Patch.Game
|
||||
if (RenderMod.IsFeatureDisabled) return;
|
||||
if (!__instance || !__instance.buttonExit)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Msg("scnEditor에 렌더 버튼을 만들 수 없습니다.");
|
||||
Melon<RenderMod>.Logger.Msg("Cannot create render button in scnEditor.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -47,5 +47,4 @@ namespace ADOFAIRenderer.Patch.Game
|
||||
iconImage.sprite = sprite;
|
||||
iconImage.rectTransform.sizeDelta = new Vector2(24, 24);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,17 +4,17 @@ using ADOFAIRenderer.Util;
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ADOFAIRenderer.Patch.Game
|
||||
namespace ADOFAIRenderer.Patch.Game;
|
||||
|
||||
[HarmonyPatch(typeof(scnGame))]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public static class scnGamePatch
|
||||
{
|
||||
[HarmonyPatch(typeof(scnGame))]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public static class scnGamePatch
|
||||
{
|
||||
[HarmonyPostfix]
|
||||
[HarmonyPatch("Play")]
|
||||
public static void ProcessFloors()
|
||||
{
|
||||
var nonTile = RenderManager.Instance.OverrideLoading &&
|
||||
var nonTile = RenderManager.Instance.stage >= RenderStage.Load &&
|
||||
RenderManager.Instance.Configuration.AdvancedConfig.NonTile;
|
||||
|
||||
if (!nonTile) return;
|
||||
@@ -97,7 +97,7 @@ namespace ADOFAIRenderer.Patch.Game
|
||||
__instance.videoBG.gameObject.SetActive(true);
|
||||
|
||||
// We can only play external video files via FFmpegNativeDecoder.
|
||||
// Internal/Bundle level background videos are built-in VideoClips, so we fallback to native Unity VideoPlayer.
|
||||
// Internal/Bundle level background videos are built-in VideoClips, so we fall back to native Unity VideoPlayer.
|
||||
if (ADOBase.isInternalLevel || ADOBase.isBundleLevel)
|
||||
{
|
||||
__instance.videoBG.enabled = true;
|
||||
@@ -138,5 +138,4 @@ namespace ADOFAIRenderer.Patch.Game
|
||||
|
||||
return false; // Skip original UpdateVideo
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using HarmonyLib;
|
||||
|
||||
namespace ADOFAIRenderer.Patch.Game
|
||||
namespace ADOFAIRenderer.Patch.Game;
|
||||
|
||||
[HarmonyPatch(typeof(scrCamera))]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public class scrCameraPatch
|
||||
{
|
||||
[HarmonyPatch(typeof(scrCamera))]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public class scrCameraPatch
|
||||
{
|
||||
[HarmonyPrefix]
|
||||
[HarmonyPatch("SetCustomFrameRate")]
|
||||
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||
@@ -19,5 +19,4 @@ namespace ADOFAIRenderer.Patch.Game
|
||||
__instance.frameRate = frame;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
using ADOFAIRenderer.Core;
|
||||
using HarmonyLib;
|
||||
|
||||
namespace ADOFAIRenderer.Patch.Game
|
||||
namespace ADOFAIRenderer.Patch.Game;
|
||||
|
||||
[HarmonyPatch(typeof(scrController))]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public static class scrControllerPatch
|
||||
{
|
||||
[HarmonyPatch(typeof(scrController))]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public static class scrControllerPatch
|
||||
{
|
||||
[HarmonyPrefix]
|
||||
[HarmonyPatch("TogglePauseGame")]
|
||||
public static bool PreventPause()
|
||||
@@ -41,5 +41,4 @@ namespace ADOFAIRenderer.Patch.Game
|
||||
{
|
||||
return !RenderManager.IsActive;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
using HarmonyLib;
|
||||
|
||||
namespace ADOFAIRenderer.Patch.Game
|
||||
namespace ADOFAIRenderer.Patch.Game;
|
||||
|
||||
[HarmonyPatch(typeof(scrDecoration))]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public static class scrDecorationPatch
|
||||
{
|
||||
[HarmonyPatch(typeof(scrDecoration))]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public static class scrDecorationPatch
|
||||
{
|
||||
[HarmonyPrefix]
|
||||
[HarmonyPatch("SetRotation")]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
@@ -37,5 +37,4 @@ namespace ADOFAIRenderer.Patch.Game
|
||||
{
|
||||
return __instance != null && __instance && __instance.pivotTrans != null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
using ADOFAIRenderer.Core;
|
||||
using HarmonyLib;
|
||||
|
||||
namespace ADOFAIRenderer.Patch.Game
|
||||
namespace ADOFAIRenderer.Patch.Game;
|
||||
|
||||
[HarmonyPatch(typeof(scrFloor))]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public class scrFloorPatch
|
||||
{
|
||||
[HarmonyPatch(typeof(scrFloor))]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public class scrFloorPatch
|
||||
{
|
||||
[HarmonyPrefix]
|
||||
[HarmonyPatch("SpawnPortalParticles")]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
@@ -14,9 +14,8 @@ namespace ADOFAIRenderer.Patch.Game
|
||||
{
|
||||
if (!scrLevelMaker.instance.listFloors.Contains(__instance)) return true;
|
||||
|
||||
var nonTile = RenderManager.Instance.OverrideLoading &&
|
||||
var nonTile = RenderManager.Instance.stage >= RenderStage.Load &&
|
||||
RenderManager.Instance.Configuration.AdvancedConfig.NonTile;
|
||||
return !nonTile;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,14 @@
|
||||
using ADOFAIRenderer.Core;
|
||||
using HarmonyLib;
|
||||
|
||||
namespace ADOFAIRenderer.Patch.Game
|
||||
namespace ADOFAIRenderer.Patch.Game;
|
||||
|
||||
[HarmonyPatch(typeof(scrHitTextManager), "ShowHitText")]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public class scrHitTextManagerPatch
|
||||
{
|
||||
[HarmonyPatch(typeof(scrHitTextManager), "ShowHitText")]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public class scrHitTextManagerPatch
|
||||
{
|
||||
public static bool Prefix()
|
||||
{
|
||||
return !RenderManager.IsActive || RenderManager.Instance is { Configuration: { ShowJudgementText: true } };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,16 @@
|
||||
using ADOFAIRenderer.Core;
|
||||
using HarmonyLib;
|
||||
|
||||
namespace ADOFAIRenderer.Patch.Game
|
||||
namespace ADOFAIRenderer.Patch.Game;
|
||||
|
||||
[HarmonyPatch(typeof(scrMistakesManager))]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public static class scrMistakesManagerPatch
|
||||
{
|
||||
[HarmonyPatch(typeof(scrMistakesManager))]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public static class scrMistakesManagerPatch
|
||||
{
|
||||
[HarmonyPrefix]
|
||||
[HarmonyPatch("SaveCustom")]
|
||||
public static bool PreventSaveCustom()
|
||||
{
|
||||
return !RenderManager.IsActive;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
using ADOFAIRenderer.Core;
|
||||
using HarmonyLib;
|
||||
|
||||
namespace ADOFAIRenderer.Patch.Game
|
||||
namespace ADOFAIRenderer.Patch.Game;
|
||||
|
||||
[HarmonyPatch(typeof(scrPlayerManager))]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public static class scrPlayerManagerPatch
|
||||
{
|
||||
[HarmonyPatch(typeof(scrPlayerManager))]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public static class scrPlayerManagerPatch
|
||||
{
|
||||
[HarmonyPrefix]
|
||||
[HarmonyPatch("AnyValidInputWasTriggered")]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
@@ -17,5 +17,4 @@ namespace ADOFAIRenderer.Patch.Game
|
||||
__result = RenderManager.Instance && RenderManager.Instance.frameCount == 0 && RenderManager.Instance.countdown == 0;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,18 +2,18 @@ using ADOFAIRenderer.Core;
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ADOFAIRenderer.Patch.Game
|
||||
namespace ADOFAIRenderer.Patch.Game;
|
||||
|
||||
[HarmonyPatch(typeof(scrPortalParticles))]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public static class scrPortalParticlesPatch
|
||||
{
|
||||
[HarmonyPatch(typeof(scrPortalParticles))]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public static class scrPortalParticlesPatch
|
||||
{
|
||||
[HarmonyPostfix]
|
||||
[HarmonyPatch("Start")]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public static void ForceScaledTime(scrPortalParticles __instance)
|
||||
{
|
||||
if (!RenderManager.Instance || !RenderManager.Instance.OverrideLoading) return;
|
||||
if (!RenderManager.Instance || RenderManager.Instance.stage < RenderStage.Load) return;
|
||||
if (__instance == null) return;
|
||||
|
||||
var particleSystems = __instance.GetComponentsInChildren<ParticleSystem>(true);
|
||||
@@ -25,5 +25,4 @@ namespace ADOFAIRenderer.Patch.Game
|
||||
main.useUnscaledTime = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
using ADOFAIRenderer.Core;
|
||||
using HarmonyLib;
|
||||
|
||||
namespace ADOFAIRenderer.Patch.Game
|
||||
namespace ADOFAIRenderer.Patch.Game;
|
||||
|
||||
[HarmonyPatch(typeof(scrPressToStart))]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public class scrPressToStartPatch
|
||||
{
|
||||
[HarmonyPatch(typeof(scrPressToStart))]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public class scrPressToStartPatch
|
||||
{
|
||||
[HarmonyPrefix]
|
||||
[HarmonyPatch("ShowText")]
|
||||
private static bool PreventShowText()
|
||||
@@ -16,5 +16,4 @@ namespace ADOFAIRenderer.Patch.Game
|
||||
RenderManager.Instance.skipPressToStart = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,12 @@ using HarmonyLib;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace ADOFAIRenderer.Patch.Game
|
||||
namespace ADOFAIRenderer.Patch.Game;
|
||||
|
||||
[HarmonyPatch(typeof(scrShowIfDebug))]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public static class scrShowIfDebugPatch
|
||||
{
|
||||
[HarmonyPatch(typeof(scrShowIfDebug))]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public static class scrShowIfDebugPatch
|
||||
{
|
||||
[HarmonyPostfix]
|
||||
[HarmonyPatch("Update")]
|
||||
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||
@@ -17,14 +17,15 @@ namespace ADOFAIRenderer.Patch.Game
|
||||
{
|
||||
if (!RenderManager.IsActive) return;
|
||||
|
||||
___txt.enabled = true;
|
||||
___txt.text = "Render";
|
||||
if (RenderManager.Instance)
|
||||
{
|
||||
if (RenderManager.Instance.countdown > 0) ___txt.text = $"Render in {RenderManager.Instance.countdown}s..";
|
||||
|
||||
if (RenderManager.Instance.wonFrame != -1)
|
||||
if (RenderManager.Instance.endFrame != -1)
|
||||
{
|
||||
var elapsedFrames = RenderManager.Instance.frameCount - RenderManager.Instance.wonFrame;
|
||||
var elapsedFrames = RenderManager.Instance.frameCount - RenderManager.Instance.endFrame;
|
||||
var remainingTime = RenderManager.Instance.Configuration.DelayAfterEnd - elapsedFrames / RenderManager.Instance.Configuration.RenderFPS;
|
||||
if (remainingTime < 0) remainingTime = 0;
|
||||
___txt.text += $"\nWaiting for {remainingTime:F1}s..";
|
||||
@@ -33,5 +34,4 @@ namespace ADOFAIRenderer.Patch.Game
|
||||
|
||||
___txt.color = ___defaultColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
using ADOFAIRenderer.Core;
|
||||
using HarmonyLib;
|
||||
|
||||
namespace ADOFAIRenderer.Patch.Game
|
||||
namespace ADOFAIRenderer.Patch.Game;
|
||||
|
||||
[HarmonyPatch(typeof(scrUIController))]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public class scrUIControllerPatch
|
||||
{
|
||||
[HarmonyPatch(typeof(scrUIController))]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public class scrUIControllerPatch
|
||||
{
|
||||
[HarmonyPostfix]
|
||||
[HarmonyPatch("ShowEndscreenLanterns")]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
@@ -17,5 +17,4 @@ namespace ADOFAIRenderer.Patch.Game
|
||||
__instance.HideEndscreenLanterns();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
# ADOFAI Renderer
|
||||
|
||||
> **Lossless, stutter-free, and high-quality video rendering for *A Dance of Fire and Ice* — even on low-end PCs.**
|
||||
|
||||
> [!NOTE]
|
||||
> This project is currently in active development. Features, APIs, and documentation are subject to change at any time.
|
||||
|
||||
---
|
||||
|
||||
## 📖 About The Project
|
||||
|
||||
**ADOFAIRenderer** was originally initiated by **square3ang** in 2024 and officially saw its first alpha release in 2025. Today, it is actively maintained under the **ADOFAI Editor Forum** as a support project dedicated to level creators.
|
||||
|
||||
By decoupling the game's simulation timestep from wall-clock time, ADOFAI Renderer enables creators to produce smooth, ultra-high-definition game videos with perfect audio sync, regardless of hardware limitations or level complexity.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Key Features
|
||||
|
||||
- **🎮 Low-Spec Friendly Offline Rendering**
|
||||
- Eliminates lag, frame drops, and stuttering caused by complex visual effects and heavy custom levels from the final video.
|
||||
- Guarantees deterministic, frame-perfect video capture.
|
||||
|
||||
- **🌐 Cross-Platform Support**
|
||||
- Fully supports **Windows**, **macOS**, and **Linux** environments.
|
||||
|
||||
- **🎥 Fully Customizable Video Output**
|
||||
- **Independent Frame Rates**: Configure `Render FPS` and `Video FPS` independently.
|
||||
- **Arbitrary Resolutions**: Render in standard HD/4K or any custom resolution.
|
||||
- **Encoding Options**: Supports modern codecs (AV1, etc.) with configurable Bitrate Control (CBR, VBR, QP/Lossless) and hardware acceleration (with software fallback).
|
||||
|
||||
- **🎵 Sample-Accurate Audio Rendering**
|
||||
- Captures and synchronizes in-game music and sound effects directly with rendered video frames.
|
||||
- Custom audio sample rate, channels (mono/stereo), and bitrate settings.
|
||||
|
||||
- **⚙️ Creator-Focused Workflow & UI**
|
||||
- **In-Game Configuration**: Easily tune video, audio, and advanced rendering parameters directly inside ADOFAI.
|
||||
- **Preset System**: Save and load custom render configurations for different use cases.
|
||||
- **Real-Time Monitor**: Live HUD tracking render queue, sent frames, memory usage, and throttling.
|
||||
- **Advanced Controls**: Toggle judgement texts, adjust post-level delay, configure random seeds, or enable Non-Tile mode.
|
||||
|
||||
- **📦 Built-in FFmpeg Integration**
|
||||
- Integrated one-click in-game FFmpeg downloader and setup for seamless installation.
|
||||
|
||||
- **🔌 Developer API**
|
||||
- Offers a fluent API (`RenderSession`) allowing other mods to programmatically configure, trigger, and control rendering sessions.
|
||||
- Full API reference and usage details are documented directly in the codebase via XML documentation comments.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Requirements
|
||||
|
||||
- **Operating System**: Windows, macOS, or Linux
|
||||
- **A Dance of Fire and Ice**
|
||||
- **[MelonLoader](https://melonwiki.xyz/)**
|
||||
- **FFmpeg** (Can be downloaded automatically via the in-game popup)
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Building from Source
|
||||
|
||||
> [!WARNING]
|
||||
> The build configuration (`ADOFAIRenderer.csproj`) is currently tailored specifically for **Windows**. Building on other operating systems (macOS, Linux) may fail or require manual adjustments to MSBuild tasks and assembly reference paths.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **.NET SDK** (supporting .NET Standard 2.1, e.g., .NET 8.0 SDK or newer)
|
||||
- **A Dance of Fire and Ice** with **[MelonLoader](https://melonwiki.xyz/)** (Mono runtime) installed.
|
||||
- **Environment Variable**: Set `ADOFAI_PATH` to your game directory (e.g., `C:/Program Files (x86)/Steam/steamapps/common/A Dance of Fire and Ice`).
|
||||
|
||||
### Compile ADOFAIRenderer
|
||||
|
||||
Once `ADOFAI_PATH` is configured in your environment:
|
||||
|
||||
```bash
|
||||
dotnet build ADOFAIRenderer.csproj -c Release
|
||||
```
|
||||
|
||||
The compiled mod will be output to `bin/Release/netstandard2.1/ADOFAIRenderer.dll`.
|
||||
|
||||
---
|
||||
|
||||
## 👥 Credits & History
|
||||
|
||||
- **Original Author**: [square3ang](https://github.com/square3ang) (2024-2025)
|
||||
- **Current Maintainer**: [ADOFAI Editor Forum](https://git.yunizun.org/ADOFAIEditorForum) (2026)
|
||||
- A community-driven initiative supporting custom level designers and video creators.
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under the GNU General Public License v3.0 (GPLv3). See `LICENSE` for details.
|
||||
+16
-43
@@ -1,75 +1,48 @@
|
||||
using System;
|
||||
using ADOFAIRenderer;
|
||||
using ADOFAIRenderer.Core;
|
||||
using ADOFAIRenderer.Util;
|
||||
using ADOFAIRenderer.Data;
|
||||
using ADOFAIRenderer.UI;
|
||||
using FFmpeg.AutoGen;
|
||||
using MelonLoader;
|
||||
using MelonLoader.Utils;
|
||||
using UnityEngine;
|
||||
using UnityEngine.EventSystems;
|
||||
using Object = UnityEngine.Object;
|
||||
|
||||
[assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.1.0-INDEV", "ADOFAI Editor Forum")]
|
||||
[assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.2.1-INDEV", "ADOFAI Editor Forum")]
|
||||
[assembly: MelonGame("7th Beat Games", "A Dance of Fire and Ice")]
|
||||
|
||||
namespace ADOFAIRenderer
|
||||
namespace ADOFAIRenderer;
|
||||
|
||||
internal class RenderMod : MelonMod
|
||||
{
|
||||
internal class RenderMod : MelonMod
|
||||
{
|
||||
public static AssetBundle FontBundle = null!;
|
||||
// ReSharper disable once MemberCanBePrivate.Global
|
||||
public static AssetBundle SceneBundle = null!;
|
||||
// ReSharper disable once MemberCanBePrivate.Global
|
||||
public static AssetBundle ShaderBundle = null!;
|
||||
public static ComputeShader ColorConversionShader = null!;
|
||||
public static bool IsFeatureDisabled = false;
|
||||
public static DateTime ExpirationTime = new(2026, 11, 21, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
public override void OnInitializeMelon()
|
||||
{
|
||||
GlobalSettings.Load();
|
||||
Localization.LocalizationManager.Initialize();
|
||||
ffmpeg.RootPath = MelonEnvironment.UserLibsDirectory;
|
||||
|
||||
var renderManagerObj = new GameObject("Render Manager");
|
||||
var updateHookerObj = new GameObject("Update Hooker");
|
||||
var uiManagerObj = new GameObject("Renderer UI Manager");
|
||||
Object.DontDestroyOnLoad(renderManagerObj);
|
||||
Object.DontDestroyOnLoad(updateHookerObj);
|
||||
Object.DontDestroyOnLoad(uiManagerObj);
|
||||
|
||||
renderManagerObj.AddComponent<RenderManager>();
|
||||
updateHookerObj.AddComponent<FirstUpdateHook>();
|
||||
LoadBundles();
|
||||
}
|
||||
|
||||
private static void LoadBundles()
|
||||
{
|
||||
FontBundle = BundleLoader.GetBundle("fonts");
|
||||
SceneBundle = BundleLoader.GetBundle("scenes");
|
||||
SceneBundle.GetAllScenePaths();
|
||||
try
|
||||
{
|
||||
ShaderBundle = BundleLoader.GetBundle("shaders");
|
||||
ColorConversionShader = ShaderBundle.LoadAsset<ComputeShader>("ColorConversion");
|
||||
Melon<RenderMod>.Logger.Msg("컬러 변환 Compute Shader 로드 완료.");
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Warning($"Shader 에셋번들을 로드할 수 없습니다. GPU 컬러 변환이 비활성화되며 CPU로 폴백합니다. 에러: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public static void FixEventSystem()
|
||||
{
|
||||
if (EventSystem.current == null) return;
|
||||
var currentModule = EventSystem.current.currentInputModule;
|
||||
|
||||
if (currentModule) return;
|
||||
Melon<RenderMod>.Logger.Msg("Unity 6 인풋 모듈 이식.");
|
||||
|
||||
var newModule = EventSystem.current.gameObject.AddComponent<StandaloneInputModule>();
|
||||
newModule.enabled = true;
|
||||
uiManagerObj.AddComponent<RendererUIManager>();
|
||||
ModAssets.Load();
|
||||
}
|
||||
|
||||
public override void OnApplicationQuit()
|
||||
{
|
||||
if (RenderManager.Instance == null) return;
|
||||
Melon<RenderMod>.Logger.Msg("MelonLoader 종료 감지: RenderManager의 활성 프로세스를 강제 수거 및 종료합니다.");
|
||||
Melon<RenderMod>.Logger.Msg("MelonLoader shutdown detected: Forcefully terminating and cleaning up active RenderManager processes.");
|
||||
RenderManager.Instance.CleanUpActiveRender();
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
-67
@@ -7,13 +7,15 @@ using UnityEngine.UIElements;
|
||||
using Button = UnityEngine.UI.Button;
|
||||
using Image = UnityEngine.UI.Image;
|
||||
|
||||
namespace ADOFAIRenderer.UI
|
||||
namespace ADOFAIRenderer.UI;
|
||||
|
||||
internal class DialogButton
|
||||
{
|
||||
internal class DialogButton
|
||||
{
|
||||
// ReSharper disable FieldCanBeMadeReadOnly.Global
|
||||
public string Text;
|
||||
public Action OnClick;
|
||||
public Color? BackgroundColor;
|
||||
// ReSharper restore FieldCanBeMadeReadOnly.Global
|
||||
|
||||
public DialogButton(string text, Action onClick, Color? backgroundColor = null)
|
||||
{
|
||||
@@ -21,10 +23,10 @@ namespace ADOFAIRenderer.UI
|
||||
OnClick = onClick;
|
||||
BackgroundColor = backgroundColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class CommonWarningDialogUI : MonoBehaviour
|
||||
{
|
||||
internal class CommonWarningDialogUI : MonoBehaviour
|
||||
{
|
||||
public static CommonWarningDialogUI? Instance { get; private set; }
|
||||
private readonly List<VisualElement> _disabledElements = new();
|
||||
|
||||
@@ -50,46 +52,6 @@ namespace ADOFAIRenderer.UI
|
||||
}
|
||||
}
|
||||
|
||||
private static TMP_FontAsset GetNanumSquareNeoBdFontAsset()
|
||||
{
|
||||
TMP_FontAsset? font = null;
|
||||
if (RenderMod.FontBundle)
|
||||
{
|
||||
try
|
||||
{
|
||||
font = RenderMod.FontBundle.LoadAsset<TMP_FontAsset>("NanumSquareNeoOTF-Bd SDF");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 로딩 실패
|
||||
}
|
||||
}
|
||||
|
||||
// 폴백 처리
|
||||
if (!font)
|
||||
{
|
||||
try
|
||||
{
|
||||
font = RDString.fontData.fontTMP;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
if (!font)
|
||||
{
|
||||
// ReSharper disable once Unity.UnknownResource
|
||||
font = Resources.Load<TMP_FontAsset>("Fonts & Materials/LiberationSans SDF");
|
||||
}
|
||||
|
||||
if (font) return font;
|
||||
var fonts = Resources.FindObjectsOfTypeAll<TMP_FontAsset>();
|
||||
if (fonts.Length > 0) font = fonts[0];
|
||||
|
||||
return font!;
|
||||
}
|
||||
|
||||
public static void ShowDialog(string title, string description, params DialogButton[] buttons)
|
||||
{
|
||||
if (buttons.Length == 0) return;
|
||||
@@ -112,22 +74,22 @@ namespace ADOFAIRenderer.UI
|
||||
|
||||
foreach (var doc in FindObjectsByType<UIDocument>(FindObjectsSortMode.None))
|
||||
{
|
||||
if (doc && doc.rootVisualElement != null && doc.rootVisualElement.enabledSelf)
|
||||
if (doc && doc.rootVisualElement is { enabledSelf: true })
|
||||
{
|
||||
doc.rootVisualElement.SetEnabled(false);
|
||||
_disabledElements.Add(doc.rootVisualElement);
|
||||
}
|
||||
}
|
||||
|
||||
// UI 레이어(5) 설정
|
||||
// Set UI layer (5)
|
||||
gameObject.layer = 5;
|
||||
|
||||
// Canvas 설정
|
||||
// Canvas setup
|
||||
var canvas = gameObject.AddComponent<Canvas>();
|
||||
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
|
||||
canvas.sortingOrder = 32766; // FFmpegDownloadUI(32767) 바로 아래
|
||||
canvas.sortingOrder = 32766; // Just below FFmpegDownloadUI (32767)
|
||||
|
||||
// 해상도 대응 캔버스 스케일러 설정
|
||||
// Responsive Canvas Scaler setup
|
||||
var scaler = gameObject.AddComponent<CanvasScaler>();
|
||||
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
|
||||
scaler.referenceResolution = new Vector2(1920, 1080);
|
||||
@@ -136,13 +98,13 @@ namespace ADOFAIRenderer.UI
|
||||
|
||||
gameObject.AddComponent<GraphicRaycaster>();
|
||||
|
||||
// 1. 전체 화면 오버레이 배경 생성
|
||||
// 1. Create full screen overlay background
|
||||
var bgPanelObj = new GameObject("BackgroundPanel");
|
||||
bgPanelObj.transform.SetParent(transform, false);
|
||||
bgPanelObj.layer = 5;
|
||||
|
||||
var bgImage = bgPanelObj.AddComponent<Image>();
|
||||
bgImage.color = new Color(0f, 0f, 0f, 0.65f); // 어두운 투명 오버레이
|
||||
bgImage.color = new Color(0f, 0f, 0f, 0.65f); // Dark transparent overlay
|
||||
bgImage.raycastTarget = true;
|
||||
|
||||
var bgRect = bgPanelObj.GetComponent<RectTransform>();
|
||||
@@ -151,19 +113,19 @@ namespace ADOFAIRenderer.UI
|
||||
bgRect.offsetMin = Vector2.zero;
|
||||
bgRect.offsetMax = Vector2.zero;
|
||||
|
||||
var font = GetNanumSquareNeoBdFontAsset();
|
||||
var font = ModAssets.NanumSquareNeoNormal;
|
||||
|
||||
// 2. 모달 컨테이너 생성
|
||||
// 2. Create modal container
|
||||
var modalContainerObj = new GameObject("ModalContainer");
|
||||
modalContainerObj.transform.SetParent(bgPanelObj.transform, false);
|
||||
modalContainerObj.layer = 5;
|
||||
|
||||
var modalImage = modalContainerObj.AddComponent<Image>();
|
||||
modalImage.color = new Color(0.08f, 0.08f, 0.1f, 0.88f); // 기존 UI의 배경 톤과 동일
|
||||
modalImage.color = new Color(0.08f, 0.08f, 0.1f, 0.88f); // Matching background tone of existing UI
|
||||
|
||||
var modalOutline = modalContainerObj.AddComponent<Outline>();
|
||||
modalOutline.effectColor = Color.white;
|
||||
modalOutline.effectDistance = new Vector2(3.0f, -3.0f); // 기존 UI 외곽선 스타일 일관성
|
||||
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);
|
||||
@@ -171,7 +133,7 @@ namespace ADOFAIRenderer.UI
|
||||
modalRect.pivot = new Vector2(0.5f, 0.5f);
|
||||
modalRect.sizeDelta = new Vector2(650, 360);
|
||||
|
||||
// 3. 타이틀 텍스트 생성
|
||||
// 3. Create title text
|
||||
var titleObj = new GameObject("TitleText");
|
||||
titleObj.transform.SetParent(modalContainerObj.transform, false);
|
||||
titleObj.layer = 5;
|
||||
@@ -180,7 +142,7 @@ namespace ADOFAIRenderer.UI
|
||||
titleText.text = title;
|
||||
titleText.fontSize = 24;
|
||||
titleText.font = font;
|
||||
titleText.color = new Color(1.0f, 0.8f, 0.2f, 1.0f); // 경고 테마에 맞는 주황/황색 계열
|
||||
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>();
|
||||
@@ -190,7 +152,7 @@ namespace ADOFAIRenderer.UI
|
||||
titleRect.anchoredPosition = new Vector2(0, -35);
|
||||
titleRect.sizeDelta = new Vector2(0, 40);
|
||||
|
||||
// 4. 본문 설명 텍스트 생성
|
||||
// 4. Create description text
|
||||
var descObj = new GameObject("DescriptionText");
|
||||
descObj.transform.SetParent(modalContainerObj.transform, false);
|
||||
descObj.layer = 5;
|
||||
@@ -210,7 +172,7 @@ namespace ADOFAIRenderer.UI
|
||||
descRect.anchoredPosition = new Vector2(0, -65);
|
||||
descRect.sizeDelta = new Vector2(-40, 120);
|
||||
|
||||
// 5. 버튼 컨테이너 생성
|
||||
// 5. Create button container
|
||||
var buttonContainerObj = new GameObject("ButtonContainer", typeof(RectTransform));
|
||||
buttonContainerObj.transform.SetParent(modalContainerObj.transform, false);
|
||||
buttonContainerObj.layer = 5;
|
||||
@@ -222,7 +184,7 @@ namespace ADOFAIRenderer.UI
|
||||
btnContainerRect.anchoredPosition = new Vector2(0, 35);
|
||||
btnContainerRect.sizeDelta = new Vector2(0, 50);
|
||||
|
||||
// 6. 가변 버튼 동적 배치 및 스타일링
|
||||
// 6. Dynamic button layout and styling
|
||||
float totalWidth = 560f;
|
||||
float spacing = 15f;
|
||||
float buttonHeight = 45f;
|
||||
@@ -243,8 +205,8 @@ namespace ADOFAIRenderer.UI
|
||||
btnObj.transform.SetParent(buttonContainerObj.transform, false);
|
||||
btnObj.layer = 5;
|
||||
|
||||
// 기본 색상 정의
|
||||
Color baseColor = btnData.BackgroundColor ?? new Color(0.2f, 0.2f, 0.23f, 1f); // 커스텀 색상 또는 회색톤 기본값
|
||||
// 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);
|
||||
|
||||
@@ -255,7 +217,7 @@ namespace ADOFAIRenderer.UI
|
||||
var btn = btnObj.AddComponent<Button>();
|
||||
SetupButtonStyle(btn, baseColor, hoverColor, pressedColor);
|
||||
|
||||
// 클릭 콜백 등록
|
||||
// Register click callback
|
||||
btn.onClick.AddListener(() =>
|
||||
{
|
||||
try
|
||||
@@ -281,7 +243,7 @@ namespace ADOFAIRenderer.UI
|
||||
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;
|
||||
@@ -319,5 +281,4 @@ namespace ADOFAIRenderer.UI
|
||||
float b = Mathf.Clamp01(color.b * factor);
|
||||
return new Color(r, g, b, color.a);
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
-57
@@ -6,23 +6,23 @@ using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace ADOFAIRenderer.UI
|
||||
namespace ADOFAIRenderer.UI;
|
||||
|
||||
internal class DebugMonitorUI : MonoBehaviour
|
||||
{
|
||||
internal class DebugMonitorUI : MonoBehaviour
|
||||
{
|
||||
// --- Progress Bar UI Configuration Constants ---
|
||||
private const float BarWidth = 800f;
|
||||
private const float BarHeight = 28f;
|
||||
private const float BarPosX = 0f;
|
||||
private const float BarPosY = -420f; // Y position relative to screen center (Reference: 1920x1080)
|
||||
private const int BarTextFontSize = 16; // 눈에 띄게 증가된 텍스트 크기
|
||||
private const int BarTextFontSize = 16; // Increased font size for visibility
|
||||
private static readonly Color BarBackgroundColor = new(0.08f, 0.08f, 0.1f, 1.0f);
|
||||
private static readonly Color BarFillColor = new(0.0f, 0.8f, 1.0f, 1.0f); // Sleek cyan/blue
|
||||
private static readonly Color BarTextColor = new(1.0f, 1.0f, 1.0f, 1.0f);
|
||||
|
||||
// --- Progress Bar Outline Configuration Constants ---
|
||||
private static readonly Color BarOutlineColor = Color.white; // 대비되는 흰색 윤곽선
|
||||
private static readonly Vector2 BarOutlineDistance = new(3.0f, -3.0f); // 3px의 굵고 직관적인 윤곽선 두께
|
||||
private static readonly Color BarOutlineColor = Color.white; // Contrasting white outline
|
||||
private static readonly Vector2 BarOutlineDistance = new(3.0f, -3.0f); // Outline thickness
|
||||
|
||||
private TextMeshProUGUI _text = null!;
|
||||
private GameObject _progressBarObj = null!;
|
||||
@@ -30,50 +30,17 @@ namespace ADOFAIRenderer.UI
|
||||
private RectTransform _fillRect = null!;
|
||||
private TextMeshProUGUI _progressText = null!;
|
||||
|
||||
private static TMP_FontAsset GetNanumSquareNeoBdFontAsset()
|
||||
{
|
||||
TMP_FontAsset? font = null;
|
||||
if (RenderMod.FontBundle != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
font = RenderMod.FontBundle.LoadAsset<TMP_FontAsset>("NanumSquareNeoOTF-Bd SDF");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 로딩 실패
|
||||
}
|
||||
}
|
||||
|
||||
// 폴백 처리: 에셋 번들에서 실패 시 게임 자체 NanumSquareNeo 또는 내장 TMP SDF 폰트 사용
|
||||
if (font == null)
|
||||
{
|
||||
font = RDString.fontData.fontTMP;
|
||||
}
|
||||
if (font == null)
|
||||
{
|
||||
// ReSharper disable once Unity.UnknownResource
|
||||
font = Resources.Load<TMP_FontAsset>("Fonts & Materials/LiberationSans SDF");
|
||||
}
|
||||
|
||||
if (font != null) return font;
|
||||
var fonts = Resources.FindObjectsOfTypeAll<TMP_FontAsset>();
|
||||
if (fonts.Length > 0) font = fonts[0];
|
||||
|
||||
return font!;
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
// UI 레이어(5) 설정
|
||||
// Set UI layer (5)
|
||||
gameObject.layer = 5;
|
||||
|
||||
// Canvas 설정
|
||||
// Canvas setup
|
||||
var canvas = gameObject.AddComponent<Canvas>();
|
||||
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
|
||||
canvas.sortingOrder = 9999;
|
||||
|
||||
// 해상도 다이내믹 스케일링 설정 (Reference: 1920x1080)
|
||||
// Dynamic resolution scaling setup (Reference: 1920x1080)
|
||||
var scaler = gameObject.AddComponent<CanvasScaler>();
|
||||
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
|
||||
scaler.referenceResolution = new Vector2(1920, 1080);
|
||||
@@ -82,13 +49,13 @@ namespace ADOFAIRenderer.UI
|
||||
|
||||
gameObject.AddComponent<GraphicRaycaster>();
|
||||
|
||||
// 배경 패널 생성
|
||||
// Create background panel
|
||||
var panelObj = new GameObject("BackgroundPanel");
|
||||
panelObj.transform.SetParent(transform, false);
|
||||
panelObj.layer = 5;
|
||||
|
||||
var image = panelObj.AddComponent<Image>();
|
||||
image.color = new Color(0.08f, 0.08f, 0.1f, 0.88f); // 약간 더 고급스러운 다크톤 아크릴 감성
|
||||
image.color = new Color(0.08f, 0.08f, 0.1f, 0.88f); // Dark tone acrylic style
|
||||
|
||||
var panelRect = panelObj.GetComponent<RectTransform>();
|
||||
panelRect.anchorMin = new Vector2(1, 1);
|
||||
@@ -97,28 +64,27 @@ namespace ADOFAIRenderer.UI
|
||||
panelRect.anchoredPosition = new Vector2(-30, -30);
|
||||
panelRect.sizeDelta = new Vector2(360, 220);
|
||||
|
||||
// 텍스트 생성
|
||||
// Create text
|
||||
var textObj = new GameObject("MonitorText");
|
||||
textObj.transform.SetParent(panelObj.transform, false);
|
||||
textObj.layer = 5;
|
||||
|
||||
_text = textObj.AddComponent<TextMeshProUGUI>();
|
||||
_text.richText = true;
|
||||
_text.fontSize = 16; // 13에서 16으로 증가
|
||||
_text.fontSize = 16;
|
||||
_text.alignment = TextAlignmentOptions.TopLeft;
|
||||
|
||||
// TMPro 텍스트 잘림 현상 예방
|
||||
// Prevent TMPro text clipping
|
||||
_text.textWrappingMode = TextWrappingModes.Normal;
|
||||
_text.overflowMode = TextOverflowModes.Overflow;
|
||||
|
||||
// NanumSquareNeo-Bd SDF 폰트 로드 및 적용
|
||||
var font = GetNanumSquareNeoBdFontAsset();
|
||||
_text.font = font;
|
||||
// Load and apply font
|
||||
_text.font = ModAssets.NanumSquareNeoNormal;
|
||||
|
||||
var textRect = textObj.GetComponent<RectTransform>();
|
||||
textRect.anchorMin = Vector2.zero;
|
||||
textRect.anchorMax = Vector2.one;
|
||||
textRect.offsetMin = new Vector2(20, 20); // 좀 더 넉넉하고 패딩 여백
|
||||
textRect.offsetMin = new Vector2(20, 20); // Padding margin
|
||||
textRect.offsetMax = new Vector2(-20, -20);
|
||||
|
||||
// --- Progress Bar UI Setup ---
|
||||
@@ -130,7 +96,7 @@ namespace ADOFAIRenderer.UI
|
||||
var bgImage = _progressBarObj.AddComponent<Image>();
|
||||
bgImage.color = BarBackgroundColor;
|
||||
|
||||
// 프로그레스 바 흰색 윤곽선 컴포넌트 추가
|
||||
// Add progress bar outline component
|
||||
var bgOutline = _progressBarObj.AddComponent<Outline>();
|
||||
bgOutline.effectColor = BarOutlineColor;
|
||||
bgOutline.effectDistance = BarOutlineDistance;
|
||||
@@ -164,12 +130,12 @@ namespace ADOFAIRenderer.UI
|
||||
|
||||
_progressText = progressTextObj.AddComponent<TextMeshProUGUI>();
|
||||
_progressText.richText = true;
|
||||
_progressText.fontSize = BarTextFontSize; // 14에서 18로 증가
|
||||
_progressText.fontSize = BarTextFontSize;
|
||||
_progressText.alignment = TextAlignmentOptions.Center;
|
||||
_progressText.font = font;
|
||||
_progressText.font = ModAssets.NanumSquareNeoNormal;
|
||||
_progressText.color = BarTextColor;
|
||||
|
||||
// 프로그레스 바 내부 텍스트 아웃라인 추가 (진행율 가독성 확보)
|
||||
// Add progress bar text outline for readability
|
||||
var textOutline = progressTextObj.AddComponent<Outline>();
|
||||
textOutline.effectColor = new Color(0f, 0f, 0f, 0.9f);
|
||||
textOutline.effectDistance = new Vector2(1.5f, -1.5f);
|
||||
@@ -205,7 +171,6 @@ namespace ADOFAIRenderer.UI
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"• Throttling Controls: {(RenderManager.Instance ? RenderManager.Instance.throttleCount : 0)} times");
|
||||
sb.AppendLine($"• Readback Hard Failures: <color=#ff3333>{(RenderManager.Instance ? RenderManager.Instance.readbackFailureCount : 0)}</color> times");
|
||||
sb.AppendLine($"• Render Skip-Retry: {(RenderManager.Instance ? RenderManager.Instance.skipCount : 0)} / {RenderManager.MaxRenderRetry}");
|
||||
|
||||
var memoryMb = GC.GetTotalMemory(false) / (1024.0 * 1024.0);
|
||||
sb.AppendLine($"• Mono Heap Memory: {memoryMb:F2} MB");
|
||||
@@ -230,5 +195,4 @@ namespace ADOFAIRenderer.UI
|
||||
if (_fillRect) _fillRect.sizeDelta = new Vector2(BarWidth * progress, 0);
|
||||
if (_progressText) _progressText.text = $"{progress * 100f:F1}% ({currentFloor}/{totalFloors})";
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-22
@@ -4,25 +4,25 @@ using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace ADOFAIRenderer.UI
|
||||
namespace ADOFAIRenderer.UI;
|
||||
|
||||
internal class EscapeHandlerUI : MonoBehaviour
|
||||
{
|
||||
internal class EscapeHandlerUI : MonoBehaviour
|
||||
{
|
||||
private GameObject _containerObj = null!;
|
||||
private TextMeshProUGUI _textComponent = null!;
|
||||
private Stopwatch? _escapeStopwatch;
|
||||
|
||||
private void Start()
|
||||
{
|
||||
// UI 레이어(5) 설정
|
||||
// Set UI layer (5)
|
||||
gameObject.layer = 5;
|
||||
|
||||
// Canvas 설정
|
||||
// Canvas setup
|
||||
var canvas = gameObject.AddComponent<Canvas>();
|
||||
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
|
||||
canvas.sortingOrder = 10000; // 디버그 모니터보다 위에 오도록 설정
|
||||
canvas.sortingOrder = 10000; // Placed above debug monitor
|
||||
|
||||
// 해상도 대응 캔버스 스케일러 설정
|
||||
// Responsive Canvas Scaler setup
|
||||
var scaler = gameObject.AddComponent<CanvasScaler>();
|
||||
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
|
||||
scaler.referenceResolution = new Vector2(1920, 1080);
|
||||
@@ -31,23 +31,22 @@ namespace ADOFAIRenderer.UI
|
||||
|
||||
gameObject.AddComponent<GraphicRaycaster>();
|
||||
|
||||
// 1. 컨테이너 및 배경 패널 오브젝트 생성
|
||||
// 1. Create container and background panel object
|
||||
_containerObj = new GameObject("EscapeContainer");
|
||||
_containerObj.transform.SetParent(transform, false);
|
||||
_containerObj.layer = 5;
|
||||
|
||||
var bgImage = _containerObj.AddComponent<Image>();
|
||||
// 고급스러운 다크톤 아크릴 감성
|
||||
bgImage.color = new Color(0.05f, 0.05f, 0.07f, 0.85f);
|
||||
bgImage.color = new Color(0.05f, 0.05f, 0.07f, 0.85f); // Dark tone acrylic style
|
||||
|
||||
var containerRect = _containerObj.GetComponent<RectTransform>();
|
||||
containerRect.anchorMin = new Vector2(0.5f, 0.5f);
|
||||
containerRect.anchorMax = new Vector2(0.5f, 0.5f);
|
||||
containerRect.pivot = new Vector2(0.5f, 0.5f);
|
||||
containerRect.anchoredPosition = Vector2.zero; // 화면 중앙
|
||||
containerRect.anchoredPosition = Vector2.zero; // Screen center
|
||||
containerRect.sizeDelta = new Vector2(460, 160);
|
||||
|
||||
// 2. 텍스트 오브젝트 생성
|
||||
// 2. Create text object
|
||||
var textObj = new GameObject("EscapeText");
|
||||
textObj.transform.SetParent(_containerObj.transform, false);
|
||||
textObj.layer = 5;
|
||||
@@ -58,7 +57,7 @@ namespace ADOFAIRenderer.UI
|
||||
_textComponent.textWrappingMode = TextWrappingModes.NoWrap;
|
||||
_textComponent.richText = true;
|
||||
|
||||
// 게임의 기본 폰트 적용 (RDString.fontData.fontTMP)
|
||||
// Apply game's default font (RDString.fontData.fontTMP)
|
||||
if (RDString.fontData.fontTMP != null)
|
||||
_textComponent.font = RDString.fontData.fontTMP;
|
||||
|
||||
@@ -68,13 +67,13 @@ namespace ADOFAIRenderer.UI
|
||||
textRect.offsetMin = new Vector2(10, 10);
|
||||
textRect.offsetMax = new Vector2(-10, -10);
|
||||
|
||||
// 초기 상태는 비활성화
|
||||
// Initial state disabled
|
||||
_containerObj.SetActive(false);
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
// 렌더링이 더 이상 활성화되어 있지 않으면 자가 파괴
|
||||
// Destroy self if rendering is no longer active
|
||||
if (!RenderManager.IsActive)
|
||||
{
|
||||
Destroy(gameObject);
|
||||
@@ -93,10 +92,10 @@ namespace ADOFAIRenderer.UI
|
||||
var elapsed = (float)_escapeStopwatch.Elapsed.TotalSeconds;
|
||||
var remaining = Mathf.Max(0f, 3f - elapsed);
|
||||
|
||||
// 남은 시간 텍스트 업데이트
|
||||
_textComponent.text = $"<color=#FF5252>탈출까지 {remaining:F1}초..</color>\n<size=40><color=#DDDDDD>Esc 키를 계속 누르고 계세요</color></size>";
|
||||
// Update remaining time text
|
||||
_textComponent.text = $"<color=#FF5252>Cancelling in {remaining:F1}s..</color>\n<size=40><color=#DDDDDD>Hold Esc to cancel render</color></size>";
|
||||
|
||||
// 마이크로 펄스 애니메이션 적용 (누르는 동안 컨테이너 전체 크기가 부드럽게 커짐)
|
||||
// Micro pulse animation (container smoothly scales up while holding)
|
||||
var scale = 1.0f + elapsed / 3.0f * 0.15f;
|
||||
_containerObj.transform.localScale = new Vector3(scale, scale, 1f);
|
||||
|
||||
@@ -104,10 +103,10 @@ namespace ADOFAIRenderer.UI
|
||||
|
||||
_escapeStopwatch.Stop();
|
||||
_escapeStopwatch = null;
|
||||
_containerObj.transform.localScale = Vector3.one; // 스케일 초기화
|
||||
_containerObj.transform.localScale = Vector3.one; // Reset scale
|
||||
_containerObj.SetActive(false);
|
||||
|
||||
// 렌더링 취소 및 씬 이탈
|
||||
// Cancel rendering and exit scene
|
||||
if (RenderManager.Instance)
|
||||
RenderManager.Instance.CancelRender();
|
||||
}
|
||||
@@ -116,7 +115,7 @@ namespace ADOFAIRenderer.UI
|
||||
if (_escapeStopwatch == null) return;
|
||||
_escapeStopwatch.Stop();
|
||||
_escapeStopwatch = null;
|
||||
_containerObj.transform.localScale = Vector3.one; // 스케일 초기화
|
||||
_containerObj.transform.localScale = Vector3.one; // Reset scale
|
||||
_containerObj.SetActive(false);
|
||||
}
|
||||
}
|
||||
@@ -127,5 +126,4 @@ namespace ADOFAIRenderer.UI
|
||||
_escapeStopwatch.Stop();
|
||||
_escapeStopwatch = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+63
-64
@@ -10,10 +10,10 @@ using UnityEngine.UI;
|
||||
using MelonLoader;
|
||||
using MelonLoader.Utils;
|
||||
|
||||
namespace ADOFAIRenderer.UI
|
||||
namespace ADOFAIRenderer.UI;
|
||||
|
||||
internal class FFmpegDownloadUI : MonoBehaviour
|
||||
{
|
||||
internal class FFmpegDownloadUI : MonoBehaviour
|
||||
{
|
||||
private GameObject _buttonContainerObj = null!;
|
||||
private GameObject _progressBarObj = null!;
|
||||
private GameObject _progressBarFillObj = null!;
|
||||
@@ -88,19 +88,19 @@ namespace ADOFAIRenderer.UI
|
||||
private static TMP_FontAsset GetNanumSquareNeoBdFontAsset()
|
||||
{
|
||||
TMP_FontAsset? font = null;
|
||||
if (RenderMod.FontBundle)
|
||||
if (ModAssets.FontBundle)
|
||||
{
|
||||
try
|
||||
{
|
||||
font = RenderMod.FontBundle.LoadAsset<TMP_FontAsset>("NanumSquareNeoOTF-Bd SDF");
|
||||
font = ModAssets.FontBundle.LoadAsset<TMP_FontAsset>("NanumSquareNeoOTF-Bd SDF");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 로딩 실패
|
||||
// Loading failed
|
||||
}
|
||||
}
|
||||
|
||||
// 폴백 처리
|
||||
// Fallback processing
|
||||
if (font == null)
|
||||
{
|
||||
try
|
||||
@@ -127,15 +127,15 @@ namespace ADOFAIRenderer.UI
|
||||
|
||||
private void Start()
|
||||
{
|
||||
// UI 레이어(5) 설정
|
||||
// Set UI layer (5)
|
||||
gameObject.layer = 5;
|
||||
|
||||
// Canvas 설정
|
||||
// Canvas setup
|
||||
var canvas = gameObject.AddComponent<Canvas>();
|
||||
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
|
||||
canvas.sortingOrder = 32767; // 최상위
|
||||
canvas.sortingOrder = 32767; // Topmost
|
||||
|
||||
// 해상도 대응 캔버스 스케일러 설정
|
||||
// Responsive Canvas Scaler setup
|
||||
var scaler = gameObject.AddComponent<CanvasScaler>();
|
||||
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
|
||||
scaler.referenceResolution = new Vector2(1920, 1080);
|
||||
@@ -144,13 +144,13 @@ namespace ADOFAIRenderer.UI
|
||||
|
||||
gameObject.AddComponent<GraphicRaycaster>();
|
||||
|
||||
// 1. 전체 화면 오버레이 배경 생성
|
||||
// 1. Create full screen overlay background
|
||||
var bgPanelObj = new GameObject("BackgroundPanel");
|
||||
bgPanelObj.transform.SetParent(transform, false);
|
||||
bgPanelObj.layer = 5;
|
||||
|
||||
var bgImage = bgPanelObj.AddComponent<Image>();
|
||||
bgImage.color = new Color(0f, 0f, 0f, 0.65f); // 어두운 투명 오버레이
|
||||
bgImage.color = new Color(0f, 0f, 0f, 0.65f); // Dark transparent overlay
|
||||
bgImage.raycastTarget = true;
|
||||
|
||||
var bgRect = bgPanelObj.GetComponent<RectTransform>();
|
||||
@@ -161,16 +161,16 @@ namespace ADOFAIRenderer.UI
|
||||
|
||||
var font = GetNanumSquareNeoBdFontAsset();
|
||||
|
||||
// 2. 모달 컨테이너 생성 (다른 UI들과 디자인 일관성을 위해 동일한 아크릴 다크톤과 흰색 굵은 외곽선 적용)
|
||||
// 2. Create modal container
|
||||
var modalContainerObj = new GameObject("ModalContainer");
|
||||
modalContainerObj.transform.SetParent(bgPanelObj.transform, false);
|
||||
modalContainerObj.layer = 5;
|
||||
|
||||
var modalImage = modalContainerObj.AddComponent<Image>();
|
||||
modalImage.color = new Color(0.08f, 0.08f, 0.1f, 0.88f); // 다른 UI의 배경 톤과 동일
|
||||
modalImage.color = new Color(0.08f, 0.08f, 0.1f, 0.88f);
|
||||
|
||||
var modalOutline = modalContainerObj.AddComponent<Outline>();
|
||||
modalOutline.effectColor = Color.white; // DebugMonitorUI와 일치하는 흰색 외곽선
|
||||
modalOutline.effectColor = Color.white;
|
||||
modalOutline.effectDistance = new Vector2(3.0f, -3.0f);
|
||||
|
||||
var modalRect = modalContainerObj.GetComponent<RectTransform>();
|
||||
@@ -179,13 +179,13 @@ namespace ADOFAIRenderer.UI
|
||||
modalRect.pivot = new Vector2(0.5f, 0.5f);
|
||||
modalRect.sizeDelta = new Vector2(600, 320);
|
||||
|
||||
// 4. 타이틀 텍스트 생성
|
||||
// 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 라이브러리 설치 필요";
|
||||
titleText.text = "FFmpeg Library Installation Required";
|
||||
titleText.fontSize = 26;
|
||||
titleText.font = font;
|
||||
titleText.color = Color.white;
|
||||
@@ -198,23 +198,23 @@ namespace ADOFAIRenderer.UI
|
||||
titleRect.anchoredPosition = new Vector2(0, -35);
|
||||
titleRect.sizeDelta = new Vector2(0, 40);
|
||||
|
||||
// 5. 본문 설명 텍스트 생성
|
||||
// 5. Create description text
|
||||
var descObj = new GameObject("DescriptionText");
|
||||
descObj.transform.SetParent(modalContainerObj.transform, false);
|
||||
descObj.layer = 5;
|
||||
|
||||
var descText = descObj.AddComponent<TextMeshProUGUI>();
|
||||
var descStrBuilder = new StringBuilder();
|
||||
descStrBuilder.AppendLine("동영상을 렌더링하려면 FFmpeg 공유 라이브러리가 필요합니다.");
|
||||
descStrBuilder.AppendLine("FFmpeg shared libraries are required to render video.");
|
||||
if (Application.platform == RuntimePlatform.OSXPlayer)
|
||||
{
|
||||
descStrBuilder.AppendLine("OSX 플랫폼의 경우 신뢰 가능한 공개 빌드가 없어 수동으로 FFmpeg 8.1을 빌드하여야 합니다.");
|
||||
descStrBuilder.AppendLine("아래 버튼을 눌러 ffmpeg 공식 웹사이트를 열 수 있습니다.");
|
||||
descStrBuilder.AppendLine("For macOS, reliable public builds are unavailable, so FFmpeg 8.1 must be built manually.");
|
||||
descStrBuilder.AppendLine("Click the button below to open the official FFmpeg website.");
|
||||
}
|
||||
else
|
||||
{
|
||||
descStrBuilder.AppendLine("아래 버튼을 눌러 인터넷에서 자동으로 다운로드하여 설치합니다.");
|
||||
descStrBuilder.AppendLine("<color=#aaaaaa><size=13>(다운로드 크기: 약 70MB, UserLibs 폴더에 압축 해제됨)</size></color>");
|
||||
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();
|
||||
@@ -231,7 +231,7 @@ namespace ADOFAIRenderer.UI
|
||||
descRect.anchoredPosition = new Vector2(0, -60);
|
||||
descRect.sizeDelta = new Vector2(-40, 100);
|
||||
|
||||
// 6. 버튼 컨테이너 생성
|
||||
// 6. Create button container
|
||||
_buttonContainerObj = new GameObject("ButtonContainer", typeof(RectTransform));
|
||||
_buttonContainerObj.transform.SetParent(modalContainerObj.transform, false);
|
||||
_buttonContainerObj.layer = 5;
|
||||
@@ -243,7 +243,7 @@ namespace ADOFAIRenderer.UI
|
||||
btnContainerRect.anchoredPosition = new Vector2(0, 35);
|
||||
btnContainerRect.sizeDelta = new Vector2(0, 50);
|
||||
|
||||
// 7. 다운로드 버튼 생성
|
||||
// 7. Create download button
|
||||
var downloadBtnObj = new GameObject("DownloadButton");
|
||||
downloadBtnObj.transform.SetParent(_buttonContainerObj.transform, false);
|
||||
downloadBtnObj.layer = 5;
|
||||
@@ -273,7 +273,7 @@ namespace ADOFAIRenderer.UI
|
||||
downloadTextObj.layer = 5;
|
||||
|
||||
var downloadText = downloadTextObj.AddComponent<TextMeshProUGUI>();
|
||||
downloadText.text = Application.platform != RuntimePlatform.OSXPlayer ? "다운로드 및 설치" : "사이트 열기";
|
||||
downloadText.text = Application.platform != RuntimePlatform.OSXPlayer ? "Download & Install" : "Open Website";
|
||||
downloadText.fontSize = 20;
|
||||
downloadText.font = font;
|
||||
downloadText.color = Color.white;
|
||||
@@ -285,7 +285,7 @@ namespace ADOFAIRenderer.UI
|
||||
downloadTextRect.offsetMin = Vector2.zero;
|
||||
downloadTextRect.offsetMax = Vector2.zero;
|
||||
|
||||
// 8. 취소 버튼 생성
|
||||
// 8. Create cancel button
|
||||
var cancelBtnObj = new GameObject("CancelButton");
|
||||
cancelBtnObj.transform.SetParent(_buttonContainerObj.transform, false);
|
||||
cancelBtnObj.layer = 5;
|
||||
@@ -310,7 +310,7 @@ namespace ADOFAIRenderer.UI
|
||||
cancelTextObj.layer = 5;
|
||||
|
||||
var cancelText = cancelTextObj.AddComponent<TextMeshProUGUI>();
|
||||
cancelText.text = "취소";
|
||||
cancelText.text = "Cancel";
|
||||
cancelText.fontSize = 20;
|
||||
cancelText.font = font;
|
||||
cancelText.color = new Color(0.9f, 0.9f, 0.9f, 1f);
|
||||
@@ -322,18 +322,18 @@ namespace ADOFAIRenderer.UI
|
||||
cancelTextRect.offsetMin = Vector2.zero;
|
||||
cancelTextRect.offsetMax = Vector2.zero;
|
||||
|
||||
// 9. 프로그레스 바 컨테이너 생성
|
||||
// 9. Create progress bar container
|
||||
_progressBarObj = new GameObject("ProgressBarContainer");
|
||||
_progressBarObj.transform.SetParent(modalContainerObj.transform, false);
|
||||
_progressBarObj.layer = 5;
|
||||
_progressBarObj.SetActive(false);
|
||||
|
||||
var barBgImg = _progressBarObj.AddComponent<Image>();
|
||||
barBgImg.color = new Color(0.08f, 0.08f, 0.1f, 1.0f); // DebugMonitorUI의 BarBackgroundColor와 동일
|
||||
barBgImg.color = new Color(0.08f, 0.08f, 0.1f, 1.0f);
|
||||
|
||||
var barBgOutline = _progressBarObj.AddComponent<Outline>();
|
||||
barBgOutline.effectColor = Color.white; // DebugMonitorUI의 BarOutlineColor와 동일
|
||||
barBgOutline.effectDistance = new Vector2(3.0f, -3.0f); // DebugMonitorUI의 BarOutlineDistance와 동일
|
||||
barBgOutline.effectColor = Color.white;
|
||||
barBgOutline.effectDistance = new Vector2(3.0f, -3.0f);
|
||||
|
||||
var barRect = _progressBarObj.GetComponent<RectTransform>();
|
||||
barRect.anchorMin = new Vector2(0.5f, 0);
|
||||
@@ -342,28 +342,28 @@ namespace ADOFAIRenderer.UI
|
||||
barRect.anchoredPosition = new Vector2(0, 75);
|
||||
barRect.sizeDelta = new Vector2(480, 24);
|
||||
|
||||
// 10. 프로그레스 바 채우기 이미지 생성
|
||||
// 10. Create 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); // DebugMonitorUI의 BarFillColor와 동일
|
||||
fillImg.color = new Color(0.0f, 0.8f, 1.0f, 1.0f);
|
||||
|
||||
_fillRect = _progressBarFillObj.GetComponent<RectTransform>();
|
||||
_fillRect.anchorMin = new Vector2(0, 0);
|
||||
_fillRect.anchorMax = new Vector2(0, 1); // anchorMax.x가 동적으로 변경됨
|
||||
_fillRect.anchorMax = new Vector2(0, 1);
|
||||
_fillRect.pivot = new Vector2(0, 0.5f);
|
||||
_fillRect.anchoredPosition = Vector2.zero;
|
||||
_fillRect.sizeDelta = Vector2.zero;
|
||||
|
||||
// 11. 프로그레스 텍스트 생성 (진행률 표시)
|
||||
// 11. Create progress text
|
||||
var progressTextObj = new GameObject("ProgressBarText");
|
||||
progressTextObj.transform.SetParent(_progressBarObj.transform, false);
|
||||
progressTextObj.layer = 5;
|
||||
|
||||
_progressText = progressTextObj.AddComponent<TextMeshProUGUI>();
|
||||
_progressText.fontSize = 16; // DebugMonitorUI의 BarTextFontSize와 일치
|
||||
_progressText.fontSize = 16;
|
||||
_progressText.font = font;
|
||||
_progressText.color = Color.white;
|
||||
_progressText.alignment = TextAlignmentOptions.Center;
|
||||
@@ -378,14 +378,14 @@ namespace ADOFAIRenderer.UI
|
||||
progressTextRect.offsetMin = Vector2.zero;
|
||||
progressTextRect.offsetMax = Vector2.zero;
|
||||
|
||||
// 12. 상태 텍스트 생성
|
||||
// 12. Create status text
|
||||
_statusTextObj = new GameObject("StatusText");
|
||||
_statusTextObj.transform.SetParent(modalContainerObj.transform, false);
|
||||
_statusTextObj.layer = 5;
|
||||
_statusTextObj.SetActive(false);
|
||||
|
||||
_statusText = _statusTextObj.AddComponent<TextMeshProUGUI>();
|
||||
_statusText.fontSize = 16; // DebugMonitorUI의 텍스트 크기와 일치
|
||||
_statusText.fontSize = 16;
|
||||
_statusText.font = font;
|
||||
_statusText.color = new Color(0.8f, 0.8f, 0.8f, 1f);
|
||||
_statusText.alignment = TextAlignmentOptions.Center;
|
||||
@@ -411,7 +411,7 @@ namespace ADOFAIRenderer.UI
|
||||
|
||||
private void Update()
|
||||
{
|
||||
// 백그라운드 스레드에서 메인 스레드로 넘겨진 작업 순차 실행
|
||||
// Execute queued actions on main thread
|
||||
while (_mainThreadQueue.TryDequeue(out var action))
|
||||
{
|
||||
action?.Invoke();
|
||||
@@ -428,20 +428,20 @@ namespace ADOFAIRenderer.UI
|
||||
if (_isDownloading) return;
|
||||
_isDownloading = true;
|
||||
|
||||
// 버튼 숨기고 프로그레스 바 및 상태 텍스트 표시
|
||||
// Hide buttons and show progress bar & status text
|
||||
_buttonContainerObj.SetActive(false);
|
||||
_progressBarObj.SetActive(true);
|
||||
_statusTextObj.SetActive(true);
|
||||
|
||||
_cts = new System.Threading.CancellationTokenSource();
|
||||
|
||||
// 백그라운드 스레드에서 다운로드 시작
|
||||
// Start download in background thread
|
||||
Task.Run(async () =>
|
||||
{
|
||||
string tempZipPath = "";
|
||||
try
|
||||
{
|
||||
UpdateStatus("다운로드 시작하는 중...", new Color(0.8f, 0.8f, 0.8f));
|
||||
UpdateStatus("Starting download...", new Color(0.8f, 0.8f, 0.8f));
|
||||
UpdateProgress(0f, "0%");
|
||||
|
||||
var userLibsDir = MelonEnvironment.UserLibsDirectory;
|
||||
@@ -461,24 +461,24 @@ namespace ADOFAIRenderer.UI
|
||||
var progressReporter = new Progress<float>(percent =>
|
||||
{
|
||||
UpdateProgress(percent, $"{percent * 100f:F1}%");
|
||||
UpdateStatus($"다운로드 중... ({(percent * 100f):F1}%)", new Color(0f, 0.8f, 1f));
|
||||
UpdateStatus($"Downloading... ({(percent * 100f):F1}%)", new Color(0f, 0.8f, 1f));
|
||||
});
|
||||
|
||||
await DownloadFileAsync(DownloadUrl, tempZipPath, progressReporter, _cts.Token);
|
||||
|
||||
// 다운로드 완료 후 압축 해제
|
||||
UpdateStatus("압축 해제 및 DLL 파일 복사 중...", new Color(0.9f, 0.9f, 0.2f));
|
||||
UpdateProgress(0.99f, "압축 해제 중...");
|
||||
// Extract after download complete
|
||||
UpdateStatus("Extracting and copying library files...", new Color(0.9f, 0.9f, 0.2f));
|
||||
UpdateProgress(0.99f, "Extracting...");
|
||||
|
||||
await ExtractDllsAsync(tempZipPath, userLibsDir);
|
||||
|
||||
// 완료 상태 업데이트
|
||||
UpdateStatus("FFmpeg 설치 완료!", new Color(0.2f, 0.9f, 0.2f));
|
||||
// Update completion status
|
||||
UpdateStatus("FFmpeg installation complete!", new Color(0.2f, 0.9f, 0.2f));
|
||||
UpdateProgress(1f, "100%");
|
||||
|
||||
await Task.Delay(1000);
|
||||
|
||||
// 메인 스레드에서 UI를 삭제
|
||||
// Destroy UI on main thread
|
||||
ExecuteOnMainThread(() =>
|
||||
{
|
||||
Destroy(gameObject);
|
||||
@@ -486,7 +486,7 @@ namespace ADOFAIRenderer.UI
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 취소 시 임시 파일 정리
|
||||
// Clean up temporary file on cancellation
|
||||
if (!string.IsNullOrEmpty(tempZipPath) && File.Exists(tempZipPath))
|
||||
{
|
||||
try { File.Delete(tempZipPath); }
|
||||
@@ -495,14 +495,14 @@ namespace ADOFAIRenderer.UI
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
UpdateStatus("다운로드가 취소되었습니다.", new Color(0.9f, 0.5f, 0.2f));
|
||||
UpdateStatus("Download was cancelled.", new Color(0.9f, 0.5f, 0.2f));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Error($"FFmpeg 다운로드/설치 중 에러 발생: {ex}");
|
||||
UpdateStatus($"설치 실패: {ex.Message}", new Color(1f, 0.3f, 0.3f));
|
||||
Melon<RenderMod>.Logger.Error($"Error during FFmpeg download/installation: {ex}");
|
||||
UpdateStatus($"Installation failed: {ex.Message}", new Color(1f, 0.3f, 0.3f));
|
||||
|
||||
// 실패 시 버튼 재설정
|
||||
// Reset buttons on failure
|
||||
ExecuteOnMainThread(() =>
|
||||
{
|
||||
_isDownloading = false;
|
||||
@@ -510,7 +510,7 @@ namespace ADOFAIRenderer.UI
|
||||
var downloadBtnText = _buttonContainerObj.transform.Find("DownloadButton/DownloadText")?.GetComponent<TextMeshProUGUI>();
|
||||
if (downloadBtnText)
|
||||
{
|
||||
downloadBtnText.text = "재시도 (Retry)";
|
||||
downloadBtnText.text = "Retry";
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -556,7 +556,7 @@ namespace ADOFAIRenderer.UI
|
||||
private async Task DownloadFileAsync(string url, string destinationPath, IProgress<float> progress, System.Threading.CancellationToken cancellationToken)
|
||||
{
|
||||
using var client = new HttpClient();
|
||||
// User-Agent 헤더 추가하여 차단 방지
|
||||
// Add User-Agent header to prevent blocking
|
||||
client.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ADOFAIRenderer/1.0");
|
||||
|
||||
using var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
@@ -611,7 +611,7 @@ namespace ADOFAIRenderer.UI
|
||||
}
|
||||
else
|
||||
{
|
||||
// 리눅스 (또는 기타 플랫폼) - tar.xz 해제
|
||||
// Linux (or other platforms) - extract tar.xz
|
||||
await Task.Run(() =>
|
||||
{
|
||||
var tempExtractDir = Path.Combine(outputDirectory, "ffmpeg_temp_extracted");
|
||||
@@ -640,11 +640,11 @@ namespace ADOFAIRenderer.UI
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
var error = process.StandardError.ReadToEnd();
|
||||
throw new Exception($"tar 압축 해제 실패 (ExitCode: {process.ExitCode}): {error}");
|
||||
throw new Exception($"tar extraction failed (ExitCode: {process.ExitCode}): {error}");
|
||||
}
|
||||
}
|
||||
|
||||
// 압축이 풀린 파일 중에서 .so 파일들을 찾아서 outputDirectory로 이동
|
||||
// Find .so files and copy to outputDirectory
|
||||
var soFiles = Directory.GetFiles(tempExtractDir, "*.so*", SearchOption.AllDirectories);
|
||||
foreach (var soFile in soFiles)
|
||||
{
|
||||
@@ -653,7 +653,7 @@ namespace ADOFAIRenderer.UI
|
||||
File.Copy(soFile, targetPath, true);
|
||||
}
|
||||
|
||||
// 임시 디렉토리 정리
|
||||
// Clean up temporary directory
|
||||
try
|
||||
{
|
||||
Directory.Delete(tempExtractDir, true);
|
||||
@@ -675,7 +675,7 @@ namespace ADOFAIRenderer.UI
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Warning($"임시 ZIP 파일 삭제 실패: {ex.Message}");
|
||||
Melon<RenderMod>.Logger.Warning($"Failed to delete temporary zip file: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -689,5 +689,4 @@ namespace ADOFAIRenderer.UI
|
||||
Instance = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,124 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using ADOFAIRenderer.Util;
|
||||
using MelonLoader;
|
||||
using O5Kit.Core;
|
||||
using TMPro;
|
||||
|
||||
namespace ADOFAIRenderer.UI;
|
||||
|
||||
using UnityEngine;
|
||||
|
||||
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
|
||||
internal static class ModAssets
|
||||
{
|
||||
public static void Load()
|
||||
{
|
||||
FontBundle = BundleLoader.GetBundle("fonts");
|
||||
NanumSquareNeoNormal = FontBundle.LoadAsset<TMP_FontAsset>("NanumSquareNeoOTF-Bd SDF");
|
||||
NanumSquareNeoBold = FontBundle.LoadAsset<TMP_FontAsset>("NanumSquareNeoOTF-Eb SDF");
|
||||
Melon<RenderMod>.Logger.Msg("Fonts loaded successfully.");
|
||||
|
||||
ShaderBundle = BundleLoader.GetBundle("shaders");
|
||||
ColorConversionShader = ShaderBundle.LoadAsset<ComputeShader>("ColorConversion");
|
||||
Melon<RenderMod>.Logger.Msg("Shader loaded successfully.");
|
||||
}
|
||||
|
||||
public static AssetBundle FontBundle = null!;
|
||||
public static AssetBundle ShaderBundle = null!;
|
||||
|
||||
public static TMP_FontAsset NanumSquareNeoNormal = null!;
|
||||
public static TMP_FontAsset NanumSquareNeoBold = null!;
|
||||
|
||||
public static ComputeShader ColorConversionShader = null!;
|
||||
}
|
||||
|
||||
internal sealed class RendererFontProvider : IFontProvider
|
||||
{
|
||||
private static readonly DefaultFontProvider DefaultFontProvider = new();
|
||||
private bool _disposed;
|
||||
|
||||
public TMP_FontAsset Regular => ModAssets.NanumSquareNeoNormal;
|
||||
public TMP_FontAsset Medium => ModAssets.NanumSquareNeoBold;
|
||||
public TMP_FontAsset Monospace => DefaultFontProvider.Monospace;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
internal class RendererUIManager : MonoBehaviour
|
||||
{
|
||||
private static RendererUIManager? _instance;
|
||||
public static RendererUIManager Instance
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_instance != null) return _instance;
|
||||
|
||||
var go = new GameObject("RendererUIManager");
|
||||
DontDestroyOnLoad(go);
|
||||
|
||||
_instance = go.AddComponent<RendererUIManager>();
|
||||
return _instance;
|
||||
}
|
||||
}
|
||||
|
||||
public static O5Context Context => Instance._context;
|
||||
public O5WindowManager WindowManager => _windowManager;
|
||||
|
||||
private O5Context _context = null!;
|
||||
private O5WindowManager _windowManager = null!;
|
||||
|
||||
private void Awake() {
|
||||
_instance = this;
|
||||
|
||||
_context = new O5Context(
|
||||
config: new O5Config {
|
||||
UIScale = 1.0f,
|
||||
MiddleClickToDefault = true,
|
||||
SliderSensitivity = 1.0f
|
||||
},
|
||||
theme: O5Theme.Dark with
|
||||
{
|
||||
PanelBG = new Color(0.067f, 0.067f, 0.067f, 1f),
|
||||
TopBar = new Color(0f, 0f, 0f, 0.6f),
|
||||
MenuBG = new Color(0f, 0f, 0f, 0.6f),
|
||||
MenuHover = new Color(1.000f, 0.294f, 0.079f, .6f),
|
||||
ObjectBG = new Color(0f, 0f, 0f, 1f),
|
||||
ObjectButton = new Color(0f, 0f, 0f, 1f),
|
||||
ObjectActive = new Color(1.000f, 0.294f, 0.079f, 1f),
|
||||
ObjectActiveBright = new Color(0.812f, 0.827f, 1.000f, 1f),
|
||||
ControlOutlineIdle = new Color(1f, 1f, 1f, 0.4f),
|
||||
ButtonHover = new Color(0.133f, 0.133f, 0.133f, 1.0f),
|
||||
ControlOutlineHover = new Color(1f, 1f, 1f, 0.4f),
|
||||
CardHeader = new Color(0.157f, 0f, 0f, 1f),
|
||||
CardPanel = new Color(0, 0, 0, 0f),
|
||||
SoftRed = new Color(1.000f, 0.576f, 0.590f, 1f),
|
||||
MathOk = new Color(0.408f, 0.827f, 0.569f, 1f),
|
||||
MathWarn = new Color(0.964f, 0.647f, 0.315f, 1f),
|
||||
MathErr = new Color(1.000f, 0.576f, 0.590f, 1f),
|
||||
CornerRadius = 12, OutlineWidth = 2, ControlHeight = 40, FontSizeBody = 18, FontSizeH1 = 48,
|
||||
Outline = new Color(1f, 1f, 1f, 0.2f),
|
||||
TextDimAlpha = 1f
|
||||
},
|
||||
fonts: new RendererFontProvider()
|
||||
);
|
||||
|
||||
_windowManager = O5WindowManager.Create(_context, transform, "RendererMod_Canvas");
|
||||
}
|
||||
|
||||
private void Update() {
|
||||
O5Object.TickAll();
|
||||
Context.Tooltip.Tick();
|
||||
O5Kit.Input.O5ShortcutManager.HandleUpdate();
|
||||
}
|
||||
|
||||
private void OnDestroy() {
|
||||
_windowManager.Dispose();
|
||||
_context.Dispose();
|
||||
|
||||
if (_instance == this) _instance = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,824 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using ADOFAIRenderer.API;
|
||||
using ADOFAIRenderer.Core;
|
||||
using ADOFAIRenderer.Data;
|
||||
using ADOFAIRenderer.Localization;
|
||||
using ADOFAIRenderer.Util;
|
||||
using ADOFAIRenderer.Util.FFmpeg;
|
||||
using HarmonyLib;
|
||||
using MelonLoader;
|
||||
using O5Kit.Control;
|
||||
using O5Kit.Core;
|
||||
using O5Kit.Factory;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace ADOFAIRenderer.UI.Window;
|
||||
|
||||
internal static class RenderConfigWindow
|
||||
{
|
||||
private static O5Window? _configWindow;
|
||||
|
||||
// Controls cache for validation & sync
|
||||
private static O5Slider? _renderFpsSlider;
|
||||
private static O5Slider? _videoFPSSlider;
|
||||
private static O5Slider? _renderWidthSlider;
|
||||
private static O5Slider? _renderHeightSlider;
|
||||
private static O5Slider? _delayAfterEndSlider;
|
||||
private static O5Toggle? _showJudgementToggle;
|
||||
|
||||
private static O5Dropdown<Codec>? _codecDropdown;
|
||||
private static O5Dropdown<RateControlMode>? _rateControlDropdown;
|
||||
private static O5InputField? _bitrateInput;
|
||||
private static O5InputField? _maxBitrateInput;
|
||||
private static O5InputField? _qpInput;
|
||||
private static O5Slider? _keyframeIntervalSlider;
|
||||
private static O5Toggle? _forceSoftwareToggle;
|
||||
|
||||
private static O5Toggle? _renderAudioToggle;
|
||||
private static O5Dropdown<int>? _sampleRateDropdown;
|
||||
private static O5Dropdown<int>? _channelsDropdown;
|
||||
private static O5Slider? _audioBitrateSlider;
|
||||
|
||||
private static O5InputField? _outputDirInput;
|
||||
|
||||
private static O5Dropdown<string>? _fileExtensionDropdown;
|
||||
private static O5InputField? _randomSeedInput;
|
||||
private static O5Toggle? _processAudioToggle;
|
||||
private static O5Toggle? _nonTileToggle;
|
||||
|
||||
public static void CreateWindow()
|
||||
{
|
||||
if (_configWindow == null)
|
||||
{
|
||||
_configWindow = RendererUIManager.Instance.WindowManager.Create(new O5WindowOptions
|
||||
{
|
||||
Id = "RendererMod_RenderConfig",
|
||||
Title = LocalizationManager.Get("ui.render_config.window_title"),
|
||||
Size = new Vector2(580f, 480f),
|
||||
Draggable = true,
|
||||
Resizable = true,
|
||||
ShowCloseButton = true
|
||||
});
|
||||
|
||||
_configWindow.CloseRequested += win =>
|
||||
{
|
||||
win.Rect.gameObject.SetActive(false);
|
||||
};
|
||||
|
||||
BuildUI(_configWindow.Content);
|
||||
}
|
||||
|
||||
// ReSharper disable once NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract
|
||||
var config = RenderManager.Instance.Configuration ??= new RenderConfig();
|
||||
ApplyConfigToUI(config);
|
||||
|
||||
SetWindowActive(true);
|
||||
_configWindow.BringToFront();
|
||||
}
|
||||
|
||||
private static void BuildUI(RectTransform content)
|
||||
{
|
||||
var ctx = RendererUIManager.Context;
|
||||
var (viewport, scrollContent, scroller) =
|
||||
O5Factory.ScrollView(ctx, content, spacing: 10f, padding: 8f);
|
||||
|
||||
// ReSharper disable once NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract
|
||||
var config = RenderManager.Instance.Configuration ??= new RenderConfig();
|
||||
EnsureDefaultConfig(config);
|
||||
|
||||
double[] renderFpsSnap = { 23.976, 24, 29.97, 30, 59.94, 60, 144,
|
||||
120, 240, 360, 480, 600, 720, // +120
|
||||
288, 576, 864, 1152, 1440, 1728, // +288
|
||||
3000 // MAX
|
||||
};
|
||||
double[] videoFpsSnap = { 23.976, 24, 29.97, 30, 59.94, 60, 72,
|
||||
120, 180, 240, 300, 360, 420, 480, 540, // +60
|
||||
144, 216, 288, 432, 504, 576, // +72
|
||||
600 // MAX
|
||||
};
|
||||
|
||||
double[] audioBitrateSnap = { 64, 96, 128, 192, 256, 320, 384 };
|
||||
|
||||
double[] renderWidthSnap = {
|
||||
256, 426, 640, 854, 960, 1280, 1366, 1600, 1920, 2048, 2560, 3200, 3840, 4096, 5120, 5760, 6016, 7680, 10240, 15360, // 16:9
|
||||
230, 384, 576, 768, 1152, 1440, 1680, 2880, 3072, // 16:10
|
||||
192, 320, 480, 800, 1024, 1400, 2732, 6400, 11520 // 4:3
|
||||
};
|
||||
|
||||
double[] renderHeightSnap = {
|
||||
144, 240, 360, 480, 540, 720, 768, 900, 1080, 1152, 1440, 1800, 2160, 2304, 2880, 3240, 3384, 4320, 5760, 8640, // 16:9
|
||||
400, 600, 800, 1050, 1200, 1600, 1920, 2400, 3200, 4800, 9600, // 16:10
|
||||
864, 960, 1536, 2048, 3072 // 4:3
|
||||
};
|
||||
|
||||
// TODO: Presets
|
||||
|
||||
// 1-3. Top section: 좌/우 컨테이너 1개씩으로 3행을 쌓는다 (중앙선 1개)
|
||||
var (topLeft, topRight) = CreateTwoColumnPanel(scrollContent, spacing: 8f, columnSpacing: 10f);
|
||||
|
||||
_renderFpsSlider = O5Factory.Slider(
|
||||
ctx: ctx, parent: CreateFieldSlot(topLeft),
|
||||
defaultValue: 60, min: 0.01, max: 3000, value: 60,
|
||||
format: "F3", clampMode: ClampMode.All,
|
||||
|
||||
sliderFilter: value => renderFpsSnap.OrderBy(n => Math.Abs(n - value)).First(),
|
||||
inputFilter: null, onComplete: null, onChanged: val => { config.RenderFPS = val; },
|
||||
text: LocalizationManager.Get("ui.render_config.render_fps"),
|
||||
id: "slider_render_fps", showFill: true
|
||||
);
|
||||
|
||||
_videoFPSSlider = O5Factory.Slider(
|
||||
ctx: ctx, parent: CreateFieldSlot(topRight),
|
||||
defaultValue: 60, min: 0.01, max: 600, value: 60,
|
||||
format: "F3", clampMode: ClampMode.All,
|
||||
|
||||
sliderFilter: value => videoFpsSnap.OrderBy(n => Math.Abs(n - value)).First(),
|
||||
inputFilter: null, onComplete: null, onChanged: val => { config.VideoFPS = val; },
|
||||
text: LocalizationManager.Get("ui.render_config.video_fps"),
|
||||
id: "slider_video_fps", showFill: true
|
||||
);
|
||||
|
||||
_renderWidthSlider = O5Factory.Slider(
|
||||
ctx: ctx, parent: CreateFieldSlot(topLeft),
|
||||
defaultValue: 2560, min: 0, max: 15360, value: 2560,
|
||||
format: "F0", clampMode: ClampMode.All,
|
||||
|
||||
sliderFilter: value => renderWidthSnap.OrderBy(n => Math.Abs(n - value)).First(), inputFilter: value => Math.Max(value, 2),
|
||||
onComplete: null, onChanged: val => { config.RenderWidth = (int)Math.Round(val); },
|
||||
text: LocalizationManager.Get("ui.render_config.render_width"),
|
||||
id: "slider_render_width", showFill: true
|
||||
);
|
||||
|
||||
_renderHeightSlider = O5Factory.Slider(
|
||||
ctx: ctx, parent: CreateFieldSlot(topRight),
|
||||
defaultValue: 1440, min: 0, max: 8640, value: 1440,
|
||||
format: "F0", clampMode: ClampMode.All,
|
||||
|
||||
sliderFilter: value => renderHeightSnap.OrderBy(n => Math.Abs(n - value)).First(), inputFilter: value => Math.Max(value, 2),
|
||||
onComplete: null, onChanged: val => { config.RenderWidth = (int)Math.Round(val); },
|
||||
text: LocalizationManager.Get("ui.render_config.render_height"),
|
||||
id: "slider_render_height", showFill: true
|
||||
);
|
||||
|
||||
_delayAfterEndSlider = O5Factory.Slider(
|
||||
ctx: ctx, parent: CreateFieldSlot(topLeft),
|
||||
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; },
|
||||
text: LocalizationManager.Get("ui.render_config.delay_after_end"),
|
||||
id: "slider_delay_after_end", showFill: true
|
||||
);
|
||||
|
||||
_showJudgementToggle = CreateToggleField(
|
||||
ctx, CreateFieldSlot(topRight), "",
|
||||
false, config.ShowJudgementText,
|
||||
LocalizationManager.Get("ui.render_config.show_judgement"),
|
||||
"toggle_show_judgement",
|
||||
val => config.ShowJudgementText = val,
|
||||
generateLabel: false
|
||||
);
|
||||
|
||||
// 4. Video Encoding Config (Collapsible Card)
|
||||
var (_, videoContent) = O5Factory.Card(
|
||||
ctx, scrollContent,
|
||||
LocalizationManager.Get("ui.render_config.video_encoding"),
|
||||
activeValue: true,
|
||||
onActiveChanged: null,
|
||||
onDeleteClick: null,
|
||||
showDeleteButton: false,
|
||||
showActiveToggle: false
|
||||
);
|
||||
|
||||
var (videoLeft, videoRight) = CreateTwoColumnPanel(videoContent);
|
||||
var codecs = (Codec[])Enum.GetValues(typeof(Codec));
|
||||
_codecDropdown = CreateDropdownField(
|
||||
ctx, CreateFieldSlot(videoLeft),
|
||||
LocalizationManager.Get("ui.render_config.codec"),
|
||||
Codec.H264, config.VideoConfig.Codec,
|
||||
codecs, c => c.ToString(),
|
||||
"dd_video_codec",
|
||||
c =>
|
||||
{
|
||||
config.VideoConfig.Codec = c;
|
||||
UpdateVideoCodec(c);
|
||||
});
|
||||
|
||||
_bitrateInput = CreateInputField(
|
||||
ctx, CreateFieldSlot(videoLeft),
|
||||
LocalizationManager.Get("ui.render_config.bitrate"),
|
||||
"30000", config.VideoConfig.Bitrate.ToString(),
|
||||
"input_bitrate",
|
||||
TMPro.TMP_InputField.CharacterValidation.Integer,
|
||||
val => { if (int.TryParse(val, out var b) && b > 0) config.VideoConfig.Bitrate = b; }
|
||||
);
|
||||
|
||||
_qpInput = CreateInputField(
|
||||
ctx, CreateFieldSlot(videoLeft),
|
||||
LocalizationManager.Get("ui.render_config.qp"),
|
||||
"18", config.VideoConfig.QP.ToString(),
|
||||
"input_qp",
|
||||
TMPro.TMP_InputField.CharacterValidation.Integer,
|
||||
val => { if (int.TryParse(val, out var qp) && qp >= 0) config.VideoConfig.QP = qp; }
|
||||
);
|
||||
|
||||
var rateModes = (RateControlMode[])Enum.GetValues(typeof(RateControlMode));
|
||||
_rateControlDropdown = CreateDropdownField(
|
||||
ctx, CreateFieldSlot(videoRight),
|
||||
LocalizationManager.Get("ui.render_config.rate_control"),
|
||||
RateControlMode.CBR, config.VideoConfig.RateControlMode,
|
||||
rateModes, m => m.ToString(),
|
||||
"dd_rate_control",
|
||||
m => config.VideoConfig.RateControlMode = m
|
||||
);
|
||||
|
||||
_maxBitrateInput = CreateInputField(
|
||||
ctx, CreateFieldSlot(videoRight),
|
||||
LocalizationManager.Get("ui.render_config.max_bitrate"),
|
||||
"50000", config.VideoConfig.MaxBitrate.ToString(),
|
||||
"input_max_bitrate",
|
||||
TMPro.TMP_InputField.CharacterValidation.Integer,
|
||||
val => { if (int.TryParse(val, out var mb) && mb > 0) config.VideoConfig.MaxBitrate = mb; }
|
||||
);
|
||||
|
||||
_forceSoftwareToggle = CreateToggleField(
|
||||
ctx, CreateFieldSlot(videoRight), "",
|
||||
false, config.VideoConfig.ForceSoftwareEncoder,
|
||||
LocalizationManager.Get("ui.render_config.force_software"),
|
||||
"toggle_force_software",
|
||||
val => config.AudioConfig.RenderAudio = val,
|
||||
generateLabel: true
|
||||
);
|
||||
|
||||
_keyframeIntervalSlider = O5Factory.Slider(
|
||||
ctx: ctx, parent: videoContent,
|
||||
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; },
|
||||
text: LocalizationManager.Get("ui.render_config.keyframe_interval"),
|
||||
id: "slider_keyframe_interval", showFill: true
|
||||
);
|
||||
|
||||
// 5. Audio Encoding Config (Collapsible Card)
|
||||
var (_, audioContent) = O5Factory.Card(
|
||||
ctx, scrollContent,
|
||||
LocalizationManager.Get("ui.render_config.audio_encoding"),
|
||||
activeValue: true,
|
||||
onActiveChanged: null,
|
||||
onDeleteClick: null,
|
||||
showDeleteButton: false,
|
||||
showActiveToggle: false
|
||||
);
|
||||
|
||||
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,
|
||||
generateLabel: false
|
||||
);
|
||||
|
||||
_audioBitrateSlider = O5Factory.Slider(
|
||||
ctx: ctx, parent: CreateFieldSlot(audioRight),
|
||||
defaultValue: 256, min: 64, max: 384, value: 256,
|
||||
format: "F0", clampMode: ClampMode.All,
|
||||
|
||||
sliderFilter: value => audioBitrateSnap.OrderBy(n => Math.Abs(n - value)).First(),
|
||||
inputFilter: null, onComplete: null, onChanged: val => { config.AudioConfig.AudioBitrate = (int)Math.Round(val) * 1000; },
|
||||
text: LocalizationManager.Get("ui.render_config.audio_bitrate"),
|
||||
id: "slider_audio_bitrate", showFill: true
|
||||
);
|
||||
|
||||
var channels = new[] { 1, 2 };
|
||||
_channelsDropdown = CreateDropdownField(
|
||||
ctx, CreateFieldSlot(audioLeft),
|
||||
LocalizationManager.Get("ui.render_config.channels"),
|
||||
2, config.AudioConfig.AudioChannels,
|
||||
channels, c => c == 1 ? "1 (Mono)" : "2 (Stereo)",
|
||||
"dd_channels",
|
||||
c => config.AudioConfig.AudioChannels = c
|
||||
);
|
||||
|
||||
_sampleRateDropdown = CreateDropdownField(
|
||||
ctx, CreateFieldSlot(audioRight),
|
||||
LocalizationManager.Get("ui.render_config.sample_rate"),
|
||||
48000, config.AudioConfig.AudioSampleRate,
|
||||
CodecUtil.AvailableSampleRate[CodecUtil.AudioCodecMatch[config.VideoConfig.Codec]], r => $"{r/1000.0} kHz",
|
||||
"dd_sample_rate",
|
||||
r => config.AudioConfig.AudioSampleRate = r
|
||||
);
|
||||
|
||||
// 6. Global Settings (Collapsible Card)
|
||||
var (_, globalContent) = O5Factory.Card(
|
||||
ctx, scrollContent,
|
||||
LocalizationManager.Get("ui.render_config.global_settings"),
|
||||
activeValue: true,
|
||||
onActiveChanged: null,
|
||||
onDeleteClick: null,
|
||||
showDeleteButton: false,
|
||||
showActiveToggle: false
|
||||
);
|
||||
|
||||
_outputDirInput = CreateFullWidthInputField(
|
||||
ctx, globalContent,
|
||||
LocalizationManager.Get("ui.render_config.output_dir"),
|
||||
GlobalSettings.DefaultOutputDirectory,
|
||||
GlobalSettings.EffectiveOutputDirectory,
|
||||
"input_output_dir",
|
||||
val =>
|
||||
{
|
||||
var trimmed = val?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(trimmed) ||
|
||||
string.Equals(trimmed, GlobalSettings.DefaultOutputDirectory, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
GlobalSettings.Current.OutputDirectory = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
GlobalSettings.Current.OutputDirectory = trimmed;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
var (globalLeft, globalRight) = CreateTwoColumnPanel(globalContent);
|
||||
O5Factory.Button(
|
||||
ctx, globalLeft,
|
||||
() =>
|
||||
{
|
||||
var initial = string.IsNullOrWhiteSpace(_outputDirInput.Value)
|
||||
? GlobalSettings.DefaultOutputDirectory
|
||||
: _outputDirInput.Value;
|
||||
FolderDialogUtil.OpenFolderBrowser(initial, selected =>
|
||||
{
|
||||
Melon<RenderMod>.Logger.Msg("Output Directory Selected: " + selected);
|
||||
if (!string.IsNullOrWhiteSpace(selected))
|
||||
{
|
||||
_outputDirInput.Set(selected);
|
||||
GlobalSettings.Current.OutputDirectory = selected;
|
||||
GlobalSettings.Save();
|
||||
}
|
||||
});
|
||||
},
|
||||
LocalizationManager.Get("ui.render_config.browse"),
|
||||
"btn_browse_output"
|
||||
);
|
||||
|
||||
O5Factory.Button(
|
||||
ctx, globalRight,
|
||||
() =>
|
||||
{
|
||||
_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(
|
||||
ctx, scrollContent,
|
||||
LocalizationManager.Get("ui.render_config.advanced_settings"),
|
||||
activeValue: true,
|
||||
onActiveChanged: null,
|
||||
onDeleteClick: null,
|
||||
showDeleteButton: false,
|
||||
showActiveToggle: false
|
||||
);
|
||||
|
||||
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"),
|
||||
"mkv", config.AdvancedConfig.FileExtension,
|
||||
extensions, e => e,
|
||||
"dd_adv_file_extension",
|
||||
e => { if (!string.IsNullOrEmpty(e)) config.AdvancedConfig.FileExtension = e; }
|
||||
);
|
||||
|
||||
_processAudioToggle = CreateToggleField(
|
||||
ctx, CreateFieldSlot(advLeft), "",
|
||||
true, config.AdvancedConfig.ProcessAudio,
|
||||
LocalizationManager.Get("ui.render_config.process_audio"),
|
||||
"toggle_process_audio",
|
||||
val => config.AdvancedConfig.ProcessAudio = val,
|
||||
generateLabel: false
|
||||
);
|
||||
|
||||
_randomSeedInput = CreateInputField(
|
||||
ctx, CreateFieldSlot(advRight),
|
||||
LocalizationManager.Get("ui.render_config.random_seed"),
|
||||
"", config.AdvancedConfig.RandomSeed?.ToString() ?? "",
|
||||
"input_random_seed",
|
||||
TMPro.TMP_InputField.CharacterValidation.Integer,
|
||||
val =>
|
||||
{
|
||||
if (int.TryParse(val, out var s)) config.AdvancedConfig.RandomSeed = s;
|
||||
else if (string.IsNullOrWhiteSpace(val)) config.AdvancedConfig.RandomSeed = null;
|
||||
}
|
||||
);
|
||||
|
||||
_nonTileToggle = CreateToggleField(
|
||||
ctx, CreateFieldSlot(advRight), "",
|
||||
false, config.AdvancedConfig.NonTile,
|
||||
LocalizationManager.Get("ui.render_config.non_tile"),
|
||||
"toggle_non_tile",
|
||||
val => config.AdvancedConfig.NonTile = val,
|
||||
generateLabel: false
|
||||
);
|
||||
|
||||
// TODO: Fire Planet, Ice Planet, Wind Planet Color Settings (Each Optional)
|
||||
|
||||
// 8. Go Render Button
|
||||
O5Factory.Button(
|
||||
ctx, scrollContent,
|
||||
TryGoRender,
|
||||
LocalizationManager.Get("ui.render_config.go_render"),
|
||||
"btn_go_render",
|
||||
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;
|
||||
versionLabel.alignment = TMPro.TextAlignmentOptions.Center;
|
||||
versionLabel.color = ctx.Theme.TextDim;
|
||||
|
||||
// ReSharper disable once InconsistentNaming
|
||||
var versionLE = versionLabel.gameObject.AddComponent<LayoutElement>();
|
||||
versionLE.preferredHeight = 18f;
|
||||
versionLE.minHeight = 18f;
|
||||
|
||||
return;
|
||||
|
||||
void UpdateVideoCodec(Codec codec)
|
||||
{
|
||||
_sampleRateDropdown?.SetValues(CodecUtil.AvailableSampleRate[CodecUtil.AudioCodecMatch[codec]]);
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureDefaultConfig(RenderConfig config)
|
||||
{
|
||||
if (config.RenderWidth <= 0) config.RenderWidth = 2560;
|
||||
if (config.RenderHeight <= 0) config.RenderHeight = 1440;
|
||||
if (config.RenderFPS <= 0) config.RenderFPS = 60.0;
|
||||
if (config.VideoFPS <= 0) config.VideoFPS = 60.0;
|
||||
if (config.DelayAfterEnd < 0) config.DelayAfterEnd = 15.0;
|
||||
}
|
||||
|
||||
private static void ApplyConfigToUI(RenderConfig config)
|
||||
{
|
||||
EnsureDefaultConfig(config);
|
||||
|
||||
_renderFpsSlider?.Set((float) config.RenderFPS, false);
|
||||
_videoFPSSlider?.Set((float) config.VideoFPS, false);
|
||||
_renderWidthSlider?.Set(config.RenderWidth, false);
|
||||
_renderHeightSlider?.Set(config.RenderHeight, false);
|
||||
_delayAfterEndSlider?.Set((float) config.DelayAfterEnd, false);
|
||||
_showJudgementToggle?.Set(config.ShowJudgementText, false);
|
||||
|
||||
_codecDropdown?.Set(config.VideoConfig.Codec, false);
|
||||
_rateControlDropdown?.Set(config.VideoConfig.RateControlMode, false);
|
||||
_bitrateInput?.Set(config.VideoConfig.Bitrate.ToString(), false);
|
||||
_maxBitrateInput?.Set(config.VideoConfig.MaxBitrate.ToString(), false);
|
||||
_qpInput?.Set(config.VideoConfig.QP.ToString(), false);
|
||||
_keyframeIntervalSlider?.Set(config.VideoConfig.KeyframeInterval, false);
|
||||
_forceSoftwareToggle?.Set(config.VideoConfig.ForceSoftwareEncoder, false);
|
||||
|
||||
_renderAudioToggle?.Set(config.AudioConfig.RenderAudio, false);
|
||||
_sampleRateDropdown?.Set(config.AudioConfig.AudioSampleRate, false);
|
||||
_channelsDropdown?.Set(config.AudioConfig.AudioChannels, false);
|
||||
_audioBitrateSlider?.Set(config.AudioConfig.AudioBitrate / 1000.0, false);
|
||||
|
||||
_outputDirInput?.Set(GlobalSettings.EffectiveOutputDirectory, false);
|
||||
|
||||
_fileExtensionDropdown?.Set(config.AdvancedConfig.FileExtension, false);
|
||||
_randomSeedInput?.Set(config.AdvancedConfig.RandomSeed?.ToString() ?? "", false);
|
||||
_processAudioToggle?.Set(config.AdvancedConfig.ProcessAudio, false);
|
||||
_nonTileToggle?.Set(config.AdvancedConfig.NonTile, false);
|
||||
}
|
||||
|
||||
private static void TryGoRender()
|
||||
{
|
||||
var config = RenderManager.Instance.Configuration;
|
||||
|
||||
// Sync Video Config
|
||||
if (_bitrateInput != null && int.TryParse(_bitrateInput.Value, out var bitrate) && bitrate > 0)
|
||||
{
|
||||
config.VideoConfig.Bitrate = bitrate;
|
||||
}
|
||||
if (_maxBitrateInput != null && int.TryParse(_maxBitrateInput.Value, out var maxBitrate) && maxBitrate > 0)
|
||||
{
|
||||
config.VideoConfig.MaxBitrate = maxBitrate;
|
||||
}
|
||||
if (_qpInput != null && int.TryParse(_qpInput.Value, out var qp) && qp >= 0)
|
||||
{
|
||||
config.VideoConfig.QP = qp;
|
||||
}
|
||||
|
||||
// Sync Advanced Config
|
||||
if (_randomSeedInput != null)
|
||||
{
|
||||
if (int.TryParse(_randomSeedInput.Value, out var seed))
|
||||
{
|
||||
config.AdvancedConfig.RandomSeed = seed;
|
||||
}
|
||||
else if (string.IsNullOrWhiteSpace(_randomSeedInput.Value))
|
||||
{
|
||||
config.AdvancedConfig.RandomSeed = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Sync Output Directory
|
||||
if (_outputDirInput != null)
|
||||
{
|
||||
var path = _outputDirInput.Value.Trim();
|
||||
if (string.IsNullOrWhiteSpace(path) ||
|
||||
string.Equals(path, GlobalSettings.DefaultOutputDirectory, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
GlobalSettings.Current.OutputDirectory = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
var fullPath = Path.GetFullPath(path);
|
||||
if (!Directory.Exists(fullPath))
|
||||
{
|
||||
Directory.CreateDirectory(fullPath);
|
||||
}
|
||||
GlobalSettings.Current.OutputDirectory = fullPath;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowValidationError($"Output directory path is invalid:\n{ex.Message}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GlobalSettings.Save();
|
||||
SetWindowActive(false);
|
||||
|
||||
new RenderSession().Start(config, RenderManager.Instance.levelPath);
|
||||
}
|
||||
|
||||
private static void ShowValidationError(string message)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Error(message);
|
||||
CommonWarningDialogUI.ShowDialog(
|
||||
LocalizationManager.Get("ui.render_config.validation_title"),
|
||||
message,
|
||||
new DialogButton("OK", () => { })
|
||||
);
|
||||
}
|
||||
|
||||
// 카드(또는 상단 섹션)당 하나의 좌/우 컨테이너를 만들어
|
||||
// 가로 한 줄 두 필드 사이 중앙이 행마다 어긋나지 않도록 한다.
|
||||
// - 패널 1개 = 중앙선 1개 (행마다 HLG를 만들던 기존 방식의 P1-P2 어긋남 방지)
|
||||
// - 좌/우 컬럼은 preferredWidth=0 + flexibleWidth=1 로 항상 50:50
|
||||
// - 각 필드는 Field 슬롯(label 18 + 간격 3 + 컨트롤 50)으로 높이를 고정해
|
||||
// 좌/우 스택을 써도 행 단위 Y 정렬이 유지되도록 한다.
|
||||
private static (RectTransform left, RectTransform right) CreateTwoColumnPanel(
|
||||
Transform parent, float spacing = 8f, float columnSpacing = 8f)
|
||||
{
|
||||
var panelObj = new GameObject("TwoColPanel");
|
||||
panelObj.transform.SetParent(parent, false);
|
||||
|
||||
panelObj.AddComponent<RectTransform>();
|
||||
|
||||
var hLayout = panelObj.AddComponent<HorizontalLayoutGroup>();
|
||||
hLayout.spacing = spacing;
|
||||
hLayout.padding = new RectOffset(0, 0, 0, 0);
|
||||
hLayout.childAlignment = TextAnchor.UpperCenter;
|
||||
hLayout.childControlWidth = true;
|
||||
hLayout.childControlHeight = true;
|
||||
hLayout.childForceExpandWidth = true;
|
||||
hLayout.childForceExpandHeight = false;
|
||||
|
||||
// ReSharper disable once InconsistentNaming
|
||||
var panelLE = panelObj.AddComponent<LayoutElement>();
|
||||
panelLE.flexibleWidth = 1f;
|
||||
|
||||
var left = CreateColumn(panelObj.transform, "LeftCol", columnSpacing);
|
||||
var right = CreateColumn(panelObj.transform, "RightCol", columnSpacing);
|
||||
|
||||
return (left, right);
|
||||
}
|
||||
|
||||
private static RectTransform CreateColumn(Transform parent, string name, float spacing)
|
||||
{
|
||||
var colObj = new GameObject(name);
|
||||
colObj.transform.SetParent(parent, false);
|
||||
var colRect = colObj.AddComponent<RectTransform>();
|
||||
|
||||
var colLayout = colObj.AddComponent<VerticalLayoutGroup>();
|
||||
colLayout.spacing = spacing;
|
||||
colLayout.padding = new RectOffset(0, 0, 0, 0);
|
||||
colLayout.childControlWidth = true;
|
||||
colLayout.childControlHeight = true;
|
||||
colLayout.childForceExpandWidth = true;
|
||||
colLayout.childForceExpandHeight = false;
|
||||
|
||||
// ReSharper disable once InconsistentNaming
|
||||
// 핵심: 좌/우 선호 너비를 0으로 강제해 항상 정확히 절반씩 나누도록 한다.
|
||||
// (기존 행마다 HLG 방식은 라벨/값 텍스트 길이에 따라 preferred가 달라져
|
||||
// 중앙이 (W + P1 - P2) / 2 로 행마다 틀어졌음)
|
||||
var colLE = colObj.AddComponent<LayoutElement>();
|
||||
colLE.minWidth = 0f;
|
||||
colLE.preferredWidth = 0f;
|
||||
colLE.flexibleWidth = 1f;
|
||||
|
||||
return colRect;
|
||||
}
|
||||
|
||||
private static RectTransform CreateFieldSlot(Transform column)
|
||||
{
|
||||
var slotObj = new GameObject("Field");
|
||||
slotObj.transform.SetParent(column, false);
|
||||
var slotRect = slotObj.AddComponent<RectTransform>();
|
||||
|
||||
var slotLayout = slotObj.AddComponent<VerticalLayoutGroup>();
|
||||
slotLayout.spacing = 3f;
|
||||
slotLayout.padding = new RectOffset(0, 0, 0, 0);
|
||||
slotLayout.childControlWidth = true;
|
||||
slotLayout.childControlHeight = true;
|
||||
slotLayout.childForceExpandWidth = true;
|
||||
slotLayout.childForceExpandHeight = false;
|
||||
|
||||
// ReSharper disable once InconsistentNaming
|
||||
var slotLE = slotObj.AddComponent<LayoutElement>();
|
||||
slotLE.flexibleWidth = 1f;
|
||||
|
||||
return slotRect;
|
||||
}
|
||||
|
||||
private static O5InputField CreateInputField(
|
||||
O5Context ctx,
|
||||
Transform colParent,
|
||||
string labelText,
|
||||
string? defaultValue,
|
||||
string value,
|
||||
string id,
|
||||
TMPro.TMP_InputField.CharacterValidation characterValidation = TMPro.TMP_InputField.CharacterValidation.None,
|
||||
Action<string>? onChanged = null)
|
||||
{
|
||||
var label = O5Factory.ControlText(ctx, colParent, 13f, noPad: true);
|
||||
label.text = labelText;
|
||||
label.color = ctx.Theme.TextDim;
|
||||
|
||||
// ReSharper disable once InconsistentNaming
|
||||
var labelLE = label.gameObject.AddComponent<LayoutElement>();
|
||||
labelLE.preferredHeight = 18f;
|
||||
labelLE.minHeight = 18f;
|
||||
|
||||
var inputField = O5Factory.Input(
|
||||
ctx: ctx,
|
||||
parent: colParent,
|
||||
defaultValue: defaultValue,
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
placeholder: labelText,
|
||||
icon: null,
|
||||
id: id
|
||||
);
|
||||
|
||||
inputField.InputField.characterValidation = characterValidation;
|
||||
return inputField;
|
||||
}
|
||||
|
||||
private static O5InputField CreateFullWidthInputField(
|
||||
O5Context ctx,
|
||||
Transform parent,
|
||||
string labelText,
|
||||
string? defaultValue,
|
||||
string value,
|
||||
string id,
|
||||
Action<string>? onChanged = null)
|
||||
{
|
||||
var container = new GameObject($"FullWidth_{id}");
|
||||
container.transform.SetParent(parent, false);
|
||||
|
||||
var containerRect = container.AddComponent<RectTransform>();
|
||||
var layout = container.AddComponent<VerticalLayoutGroup>();
|
||||
layout.spacing = 3f;
|
||||
layout.childControlWidth = true;
|
||||
layout.childControlHeight = true;
|
||||
layout.childForceExpandWidth = true;
|
||||
layout.childForceExpandHeight = false;
|
||||
|
||||
var label = O5Factory.ControlText(ctx, containerRect, 13f, noPad: true);
|
||||
label.text = labelText;
|
||||
label.color = ctx.Theme.TextDim;
|
||||
|
||||
// ReSharper disable once InconsistentNaming
|
||||
var labelLE = label.gameObject.AddComponent<LayoutElement>();
|
||||
labelLE.preferredHeight = 18f;
|
||||
labelLE.minHeight = 18f;
|
||||
|
||||
var input = O5Factory.Input(
|
||||
ctx: ctx,
|
||||
parent: containerRect,
|
||||
defaultValue: defaultValue,
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
placeholder: labelText,
|
||||
icon: null,
|
||||
id: id
|
||||
);
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
private static O5Toggle CreateToggleField(
|
||||
O5Context ctx,
|
||||
Transform colParent,
|
||||
string labelText,
|
||||
bool? defaultValue,
|
||||
bool value,
|
||||
string toggleText,
|
||||
string id,
|
||||
Action<bool>? onChanged = null,
|
||||
bool generateLabel = true)
|
||||
{
|
||||
if (generateLabel)
|
||||
{
|
||||
var label = O5Factory.ControlText(ctx, colParent, 13f, noPad: true);
|
||||
label.text = labelText;
|
||||
label.color = ctx.Theme.TextDim;
|
||||
|
||||
// ReSharper disable once InconsistentNaming
|
||||
var labelLE = label.gameObject.AddComponent<LayoutElement>();
|
||||
labelLE.preferredHeight = 18f;
|
||||
labelLE.minHeight = 18f;
|
||||
}
|
||||
|
||||
return O5Factory.Toggle(
|
||||
ctx: ctx,
|
||||
parent: colParent,
|
||||
defaultValue: defaultValue,
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
text: toggleText,
|
||||
id: id
|
||||
);
|
||||
}
|
||||
|
||||
private static O5Dropdown<T> CreateDropdownField<T>(
|
||||
O5Context ctx,
|
||||
Transform colParent,
|
||||
string labelText,
|
||||
T defaultValue,
|
||||
T value,
|
||||
IReadOnlyList<T> values,
|
||||
Func<T, string> display,
|
||||
string id,
|
||||
Action<T>? onChanged = null)
|
||||
{
|
||||
var label = O5Factory.ControlText(ctx, colParent, 13f, noPad: true);
|
||||
label.text = labelText;
|
||||
label.color = ctx.Theme.TextDim;
|
||||
|
||||
// ReSharper disable once InconsistentNaming
|
||||
var labelLE = label.gameObject.AddComponent<LayoutElement>();
|
||||
labelLE.preferredHeight = 18f;
|
||||
labelLE.minHeight = 18f;
|
||||
|
||||
var ddContainer = new GameObject($"DD_{id}");
|
||||
ddContainer.transform.SetParent(colParent, false);
|
||||
|
||||
// ReSharper disable once InconsistentNaming
|
||||
var ddLE = ddContainer.AddComponent<LayoutElement>();
|
||||
ddLE.preferredHeight = 50f;
|
||||
ddLE.minHeight = 0f;
|
||||
|
||||
return O5Factory.DropDown(
|
||||
ctx: ctx,
|
||||
parent: ddContainer.transform,
|
||||
defaultValue: defaultValue,
|
||||
value: value,
|
||||
values: values,
|
||||
display: display,
|
||||
onChanged: val => { if (val != null) onChanged?.Invoke(val); },
|
||||
id: id
|
||||
);
|
||||
}
|
||||
|
||||
private static void SetWindowActive(bool enabled)
|
||||
{
|
||||
_configWindow?.Rect.gameObject.SetActive(enabled);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
using System;
|
||||
|
||||
namespace ADOFAIRenderer.Util.Audio
|
||||
namespace ADOFAIRenderer.Util.Audio;
|
||||
|
||||
internal static class AudioResampler
|
||||
{
|
||||
internal static class AudioResampler
|
||||
{
|
||||
public static float[] Resample(float[] inputSamples, int inputChannels, int inputFrequency, int targetChannels, int targetFrequency)
|
||||
{
|
||||
// ── Safety Validation ──
|
||||
@@ -97,5 +97,4 @@ namespace ADOFAIRenderer.Util.Audio
|
||||
|
||||
return outputSamples;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,16 @@ using System.Collections.Generic;
|
||||
using ADOFAIRenderer.Core;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ADOFAIRenderer.Util.Audio
|
||||
namespace ADOFAIRenderer.Util.Audio;
|
||||
|
||||
internal static class AudioSpectrumGenerator
|
||||
{
|
||||
internal static class AudioSpectrumGenerator
|
||||
{
|
||||
// ReSharper disable MemberCanBePrivate.Global
|
||||
public static float[]? AudioClipData { get; private set; }
|
||||
public static int Channels { get; private set; }
|
||||
public static int Frequency { get; private set; }
|
||||
public static int TotalSamples { get; private set; }
|
||||
// ReSharper restore MemberCanBePrivate.Global
|
||||
|
||||
private static long _cachedFrame = -1;
|
||||
private static readonly Dictionary<int, float[]> CachedSpectrums = new();
|
||||
@@ -236,5 +238,4 @@ namespace ADOFAIRenderer.Util.Audio
|
||||
spectrum[i] = (float)Math.Sqrt(re[i] * re[i] + im[i] * im[i]) * factor;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
using System;
|
||||
|
||||
namespace ADOFAIRenderer.Util.Audio
|
||||
namespace ADOFAIRenderer.Util.Audio;
|
||||
|
||||
internal class AudioSyncCalculator
|
||||
{
|
||||
internal class AudioSyncCalculator
|
||||
{
|
||||
private readonly long _sampleRate;
|
||||
private readonly (long Numerator, long Denominator) _fpsRational;
|
||||
|
||||
@@ -37,5 +37,4 @@ namespace ADOFAIRenderer.Util.Audio
|
||||
_currentSample += samplesNeeded;
|
||||
return (int)samplesNeeded;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
using System;
|
||||
|
||||
namespace ADOFAIRenderer.Util.Audio.Filter
|
||||
namespace ADOFAIRenderer.Util.Audio.Filter;
|
||||
|
||||
internal class Gain : IAudioFilter
|
||||
{
|
||||
internal class Gain : IAudioFilter
|
||||
{
|
||||
public float Level = 0f;
|
||||
|
||||
public void Initialize(int sampleRate, int channels) { }
|
||||
@@ -20,5 +20,4 @@ namespace ADOFAIRenderer.Util.Audio.Filter
|
||||
public void Reset() { }
|
||||
|
||||
public double Latency => 0.0;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
using System;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace ADOFAIRenderer.Util.Audio.Filter
|
||||
namespace ADOFAIRenderer.Util.Audio.Filter;
|
||||
|
||||
internal class HardClip : IAudioFilter
|
||||
{
|
||||
internal class HardClip : IAudioFilter
|
||||
{
|
||||
// ReSharper disable once MemberCanBePrivate.Global
|
||||
// ReSharper disable once FieldCanBeMadeReadOnly.Global
|
||||
// ReSharper disable once ConvertToConstant.Global
|
||||
@@ -29,5 +29,4 @@ namespace ADOFAIRenderer.Util.Audio.Filter
|
||||
public void Reset() { }
|
||||
|
||||
public double Latency => 0.0;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
using System;
|
||||
|
||||
namespace ADOFAIRenderer.Util.Audio.Filter
|
||||
namespace ADOFAIRenderer.Util.Audio.Filter;
|
||||
|
||||
internal interface IAudioFilter
|
||||
{
|
||||
internal interface IAudioFilter
|
||||
{
|
||||
void Initialize(int sampleRate, int channels);
|
||||
|
||||
float ProcessSample(float sample, int channel);
|
||||
@@ -12,5 +12,4 @@ namespace ADOFAIRenderer.Util.Audio.Filter
|
||||
void Reset();
|
||||
|
||||
double Latency { get; }
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
using System;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace ADOFAIRenderer.Util.Audio.Filter
|
||||
namespace ADOFAIRenderer.Util.Audio.Filter;
|
||||
|
||||
// ReSharper disable once InconsistentNaming
|
||||
internal class RMSCompressor : IAudioFilter
|
||||
{
|
||||
// ReSharper disable once InconsistentNaming
|
||||
internal class RMSCompressor : IAudioFilter
|
||||
{
|
||||
private float _thresholdDb = -20f;
|
||||
private float _ratio = 4f;
|
||||
private float _attackTime = 0.010f; // in seconds
|
||||
@@ -219,11 +219,10 @@ namespace ADOFAIRenderer.Util.Audio.Filter
|
||||
Array.Clear(_rmsState, 0, _rmsState.Length);
|
||||
Array.Clear(_gainState, 0, _gainState.Length);
|
||||
|
||||
for (int i = 0; i < _delayBuffers.Length; i++)
|
||||
foreach (var delayBuffer in _delayBuffers)
|
||||
{
|
||||
Array.Clear(_delayBuffers[i], 0, _delayBuffers[i].Length);
|
||||
Array.Clear(delayBuffer, 0, delayBuffer.Length);
|
||||
}
|
||||
Array.Clear(_delayWriteIndices, 0, _delayWriteIndices.Length);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,10 @@ using ADOFAIRenderer.Util.Audio.Filter;
|
||||
using MelonLoader;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ADOFAIRenderer.Util.Audio
|
||||
namespace ADOFAIRenderer.Util.Audio;
|
||||
|
||||
internal class AudioClipCache
|
||||
{
|
||||
internal class AudioClipCache
|
||||
{
|
||||
private readonly Dictionary<string, CachedClipData> _cache = new();
|
||||
|
||||
public class CachedClipData
|
||||
@@ -67,10 +67,10 @@ namespace ADOFAIRenderer.Util.Audio
|
||||
_cache.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class OfflineAudioMixer
|
||||
{
|
||||
internal class OfflineAudioMixer
|
||||
{
|
||||
public class SoundEvent
|
||||
{
|
||||
public string ClipName = null!;
|
||||
@@ -413,5 +413,4 @@ namespace ADOFAIRenderer.Util.Audio
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,10 @@ using System.Resources;
|
||||
using MelonLoader;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ADOFAIRenderer.Util
|
||||
namespace ADOFAIRenderer.Util;
|
||||
|
||||
internal static class BundleLoader
|
||||
{
|
||||
internal static class BundleLoader
|
||||
{
|
||||
private static readonly string BundlePlatform;
|
||||
private static readonly Assembly? ExecutingAssembly;
|
||||
|
||||
@@ -33,12 +33,11 @@ namespace ADOFAIRenderer.Util
|
||||
return AssetBundle.LoadFromStream(resourceStream);
|
||||
}
|
||||
|
||||
Melon<RenderMod>.Logger.Error($"DLL에서 번들 리소스를 찾을 수 없습니다.\n{bundlePath}");
|
||||
Melon<RenderMod>.Logger.Error($"Could not find bundle resource in DLL.\n{bundlePath}");
|
||||
throw new MissingManifestResourceException("Could not find bundle resource in DLL.");
|
||||
}
|
||||
|
||||
Melon<RenderMod>.Logger.Error("실행 어셈블리가 제대로 설정되지 않았습니다.");
|
||||
Melon<RenderMod>.Logger.Error("Executing assembly has not been set properly.");
|
||||
throw new InvalidOperationException("ExecutingAssembly has not been set.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
-1
@@ -1,4 +1,6 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using FFmpeg.AutoGen;
|
||||
|
||||
namespace ADOFAIRenderer.Util.FFmpeg
|
||||
{
|
||||
@@ -7,4 +9,22 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
{
|
||||
H264, H265, VP9, AV1, VVC
|
||||
}
|
||||
|
||||
internal static class CodecUtil
|
||||
{
|
||||
public static readonly Dictionary<Codec, AVCodecID> AudioCodecMatch = new()
|
||||
{
|
||||
[Codec.H264] = AVCodecID.AV_CODEC_ID_AAC,
|
||||
[Codec.H265] = AVCodecID.AV_CODEC_ID_AAC,
|
||||
[Codec.VP9] = AVCodecID.AV_CODEC_ID_OPUS,
|
||||
[Codec.AV1] = AVCodecID.AV_CODEC_ID_OPUS,
|
||||
[Codec.VVC] = AVCodecID.AV_CODEC_ID_OPUS
|
||||
};
|
||||
|
||||
public static readonly Dictionary<AVCodecID, List<int>> AvailableSampleRate = new()
|
||||
{
|
||||
[AVCodecID.AV_CODEC_ID_AAC] = new List<int> { 44100, 48000, 96000 },
|
||||
[AVCodecID.AV_CODEC_ID_OPUS] = new List<int> { 48000 }
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,8 @@ using Unity.Collections.LowLevel.Unsafe;
|
||||
namespace ADOFAIRenderer.Util.FFmpeg
|
||||
{
|
||||
/// <summary>
|
||||
/// FFmpeg의 C 네이티브 API를 사용하여 비디오 파일을 스트리밍 방식으로 디코딩하는 클래스입니다.
|
||||
/// 플레이어 형태가 아니며, 필요할 때 프레임을 순차적으로 읽거나 앞으로 탐색(Seek/Skip)할 수 있습니다.
|
||||
/// Decodes video files in a streaming fashion using FFmpeg's native C API.
|
||||
/// Unlike a standalone player, it allows reading frames sequentially or seeking/skipping forward on demand.
|
||||
/// </summary>
|
||||
internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
||||
{
|
||||
@@ -26,41 +26,41 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
private bool _isDraining;
|
||||
private readonly object _lock = new();
|
||||
|
||||
// 탐색(Seek) 후 매칭되는 프레임을 보관하기 위한 캐시 프레임
|
||||
// Cached frame used to hold matching frames after seeking
|
||||
private AVFrame* _pCachedFrame = null;
|
||||
private bool _hasCachedFrame;
|
||||
|
||||
// SwsContext 갱신 감지용 필드
|
||||
// Field to detect SwsContext updates
|
||||
private AVPixelFormat _srcPixFmt = AVPixelFormat.AV_PIX_FMT_NONE;
|
||||
private AVPixelFormat _dstPixFmt = AVPixelFormat.AV_PIX_FMT_NONE;
|
||||
private int _srcWidth;
|
||||
private int _srcHeight;
|
||||
|
||||
/// <summary>비디오 가로 해상도 (픽셀).</summary>
|
||||
/// <summary>Video width in pixels.</summary>
|
||||
public int Width { get; private set; }
|
||||
|
||||
/// <summary>비디오 세로 해상도 (픽셀).</summary>
|
||||
/// <summary>Video height in pixels.</summary>
|
||||
public int Height { get; private set; }
|
||||
|
||||
/// <summary>초당 프레임 수.</summary>
|
||||
/// <summary>Frames per second.</summary>
|
||||
public double FPS { get; private set; }
|
||||
|
||||
/// <summary>전체 재생 시간 (초).</summary>
|
||||
/// <summary>Total duration in seconds.</summary>
|
||||
public double Duration { get; private set; }
|
||||
|
||||
/// <summary>전체 프레임 수 (추정치).</summary>
|
||||
/// <summary>Estimated total number of frames.</summary>
|
||||
public long TotalFrames { get; private set; }
|
||||
|
||||
/// <summary>현재 읽은 프레임의 인덱스.</summary>
|
||||
/// <summary>Index of the currently read frame.</summary>
|
||||
public long CurrentFrameIndex => _currentFrameIndex;
|
||||
|
||||
/// <summary>현재 재생 시간 (초).</summary>
|
||||
/// <summary>Current playback time in seconds.</summary>
|
||||
public double CurrentTime => _currentFrameIndex / FPS;
|
||||
|
||||
/// <summary>비디오 원본 코덱의 픽셀 포맷.</summary>
|
||||
/// <summary>Pixel format of the original video stream.</summary>
|
||||
public AVPixelFormat PixelFormat => _pCodecContext != null ? _pCodecContext->pix_fmt : AVPixelFormat.AV_PIX_FMT_NONE;
|
||||
|
||||
/// <summary>비디오 원본 코덱의 색상 범위.</summary>
|
||||
/// <summary>Color range of the original video stream.</summary>
|
||||
public AVColorRange ColorRange => _pCodecContext != null ? _pCodecContext->color_range : AVColorRange.AVCOL_RANGE_UNSPECIFIED;
|
||||
|
||||
public FFmpegNativeDecoder(string videoPath)
|
||||
@@ -80,11 +80,11 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
{
|
||||
int ret = ffmpeg.avformat_open_input(ppFormatCtx, videoPath, null, null);
|
||||
if (ret < 0)
|
||||
throw new Exception($"비디오 파일을 열 수 없습니다: {videoPath} ({GetFFmpegErrorString(ret)})");
|
||||
throw new Exception($"Failed to open video file: {videoPath} ({GetFFmpegErrorString(ret)})");
|
||||
}
|
||||
|
||||
if (ffmpeg.avformat_find_stream_info(_pFormatContext, null) < 0)
|
||||
throw new Exception("스트림 정보를 읽을 수 없습니다.");
|
||||
throw new Exception("Failed to retrieve stream info.");
|
||||
|
||||
_videoStreamIndex = -1;
|
||||
for (int i = 0; i < _pFormatContext->nb_streams; i++)
|
||||
@@ -97,12 +97,12 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
}
|
||||
|
||||
if (_videoStreamIndex == -1)
|
||||
throw new Exception("비디오 스트림을 찾을 수 없습니다.");
|
||||
throw new Exception("Failed to find video stream.");
|
||||
|
||||
var stream = _pFormatContext->streams[_videoStreamIndex];
|
||||
_timeBase = stream->time_base;
|
||||
|
||||
// FPS 계산
|
||||
// Calculate FPS
|
||||
if (stream->r_frame_rate.num > 0 && stream->r_frame_rate.den > 0)
|
||||
{
|
||||
FPS = ffmpeg.av_q2d(stream->r_frame_rate);
|
||||
@@ -113,32 +113,32 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
}
|
||||
else
|
||||
{
|
||||
FPS = 30.0; // 기본값 폴백
|
||||
FPS = 30.0; // Fallback default
|
||||
}
|
||||
|
||||
var codecId = stream->codecpar->codec_id;
|
||||
var decoder = ffmpeg.avcodec_find_decoder(codecId);
|
||||
if (decoder == null)
|
||||
throw new Exception($"지원하지 않는 비디오 코덱입니다: {codecId}");
|
||||
throw new Exception($"Unsupported video codec: {codecId}");
|
||||
|
||||
_pCodecContext = ffmpeg.avcodec_alloc_context3(decoder);
|
||||
if (_pCodecContext == null)
|
||||
throw new Exception("코덱 컨텍스트를 할당할 수 없습니다.");
|
||||
throw new Exception("Failed to allocate codec context.");
|
||||
|
||||
if (ffmpeg.avcodec_parameters_to_context(_pCodecContext, stream->codecpar) < 0)
|
||||
throw new Exception("코덱 파라미터를 복사할 수 없습니다.");
|
||||
throw new Exception("Failed to copy codec parameters.");
|
||||
|
||||
// 디코딩 성능 향상을 위한 멀티스레딩 설정 (자동)
|
||||
// Multi-threading settings for improved decoding performance (auto)
|
||||
_pCodecContext->thread_count = 0;
|
||||
_pCodecContext->thread_type = ffmpeg.FF_THREAD_FRAME;
|
||||
|
||||
if (ffmpeg.avcodec_open2(_pCodecContext, decoder, null) < 0)
|
||||
throw new Exception("코덱을 열 수 없습니다.");
|
||||
throw new Exception("Failed to open codec.");
|
||||
|
||||
Width = _pCodecContext->width;
|
||||
Height = _pCodecContext->height;
|
||||
|
||||
// 총 프레임 수 및 재생 시간 계산
|
||||
// Calculate total frames and duration
|
||||
if (stream->nb_frames > 0)
|
||||
{
|
||||
TotalFrames = stream->nb_frames;
|
||||
@@ -183,7 +183,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
{
|
||||
ffmpeg.av_packet_unref(_pPacket);
|
||||
int ret = ffmpeg.av_read_frame(_pFormatContext, _pPacket);
|
||||
if (ret < 0) return false; // EOF 또는 에러
|
||||
if (ret < 0) return false; // EOF or error
|
||||
|
||||
if (_pPacket->stream_index == _videoStreamIndex)
|
||||
{
|
||||
@@ -191,7 +191,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
ffmpeg.av_packet_unref(_pPacket);
|
||||
if (ret < 0)
|
||||
{
|
||||
throw new Exception($"디코더에 패킷을 전송하는 중 오류 발생: {GetFFmpegErrorString(ret)}");
|
||||
throw new Exception($"Error sending packet to decoder: {GetFFmpegErrorString(ret)}");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -224,7 +224,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
|
||||
if (_pSwsContext == null)
|
||||
{
|
||||
throw new Exception("SwsContext를 초기화할 수 없습니다.");
|
||||
throw new Exception("Failed to initialize SwsContext.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,13 +251,13 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 다음 비디오 프레임을 읽고 RGBA 형태로 복사합니다.
|
||||
/// Reads the next video frame and copies it in RGBA format.
|
||||
/// </summary>
|
||||
/// <param name="outBuffer">RGBA 데이터를 저장할 버퍼의 포인터</param>
|
||||
/// <param name="outStride">출력 버퍼의 가로 한 행의 바이트 크기 (일반적으로 Width * 4)</param>
|
||||
/// <param name="vflip">Unity 텍스처 시스템에 맞게 상하 flip을 적용할지 여부</param>
|
||||
/// <param name="timestamp">출력된 프레임의 타임스탬프 (초)</param>
|
||||
/// <returns>프레임을 성공적으로 디코딩하여 복사했으면 true, 재생이 끝났거나 실패했으면 false</returns>
|
||||
/// <param name="outBuffer">Pointer to the output buffer storing RGBA data.</param>
|
||||
/// <param name="outStride">Byte stride per row of the output buffer (typically Width * 4).</param>
|
||||
/// <param name="vflip">Whether to flip vertically to match Unity's texture coordinate system.</param>
|
||||
/// <param name="timestamp">Timestamp of the output frame in seconds.</param>
|
||||
/// <returns>True if the frame was successfully decoded and copied; false on EOF or failure.</returns>
|
||||
private bool TryReadFrame(byte* outBuffer, int outStride, bool vflip, out double timestamp)
|
||||
{
|
||||
timestamp = 0;
|
||||
@@ -339,7 +339,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new Exception($"디코딩 중 오류 발생: {GetFFmpegErrorString(ret)}");
|
||||
throw new Exception($"Error during decoding: {GetFFmpegErrorString(ret)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -354,16 +354,17 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
{
|
||||
if (outBuffer.Length < Width * Height * 4)
|
||||
{
|
||||
throw new ArgumentException("출력 버퍼의 크기가 비디오 해상도보다 작습니다.", nameof(outBuffer));
|
||||
throw new ArgumentException("Output buffer size is smaller than video resolution.", nameof(outBuffer));
|
||||
}
|
||||
int stride = Width * 4;
|
||||
return TryReadFrame((byte*)outBuffer.GetUnsafePtr(), stride, vflip, out timestamp);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 다음 비디오 프레임을 읽어 Y, U, V 채널 데이터를 각각 복사합니다.
|
||||
/// 비디오 원본이 YUV420p 계열(YUV420P, YUVJ420P)이면 CPU 컬러 변환(sws_scale)을 우회하고 메모리를 다이렉트로 복사하여 속도가 매우 빠릅니다.
|
||||
/// 그 외 포맷일 경우 자동으로 YUV420p로 변환 후 복사합니다.
|
||||
/// Reads the next video frame and copies its Y, U, and V channel data separately.
|
||||
/// If the source format is YUV420P family (YUV420P, YUVJ420P), CPU color conversion (sws_scale) is bypassed
|
||||
/// and memory is copied directly for maximum performance.
|
||||
/// Other formats are automatically converted to YUV420P before copying.
|
||||
/// </summary>
|
||||
// ReSharper disable once InconsistentNaming
|
||||
private bool TryReadYUV420PFrame(
|
||||
@@ -411,7 +412,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new Exception($"디코딩 중 오류 발생: {GetFFmpegErrorString(ret)}");
|
||||
throw new Exception($"Error during decoding: {GetFFmpegErrorString(ret)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -445,7 +446,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
int uvW = w / 2;
|
||||
int uvH = h / 2;
|
||||
|
||||
// Y plane 복사
|
||||
// Copy Y plane
|
||||
for (int y = 0; y < h; y++)
|
||||
{
|
||||
Buffer.MemoryCopy(
|
||||
@@ -455,7 +456,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
);
|
||||
}
|
||||
|
||||
// U plane 복사
|
||||
// Copy U plane
|
||||
for (int y = 0; y < uvH; y++)
|
||||
{
|
||||
Buffer.MemoryCopy(
|
||||
@@ -465,7 +466,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
);
|
||||
}
|
||||
|
||||
// V plane 복사
|
||||
// Copy V plane
|
||||
for (int y = 0; y < uvH; y++)
|
||||
{
|
||||
Buffer.MemoryCopy(
|
||||
@@ -495,7 +496,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
|
||||
if (yBuffer.Length < w * h || uBuffer.Length < uvW * uvH || vBuffer.Length < uvW * uvH)
|
||||
{
|
||||
throw new ArgumentException("버퍼 크기가 비디오 YUV 평면 해상도보다 작습니다.");
|
||||
throw new ArgumentException("Buffer size is smaller than video YUV plane resolution.");
|
||||
}
|
||||
|
||||
return TryReadYUV420PFrame(
|
||||
@@ -507,9 +508,10 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 다음 비디오 프레임을 읽어 NV12 형식의 Y, UV 채널 데이터를 각각 복사합니다.
|
||||
/// 비디오 원본이 NV12 계열이면 CPU 컬러 변환(sws_scale)을 우회하고 메모리를 다이렉트로 복사하여 속도가 매우 빠릅니다.
|
||||
/// 그 외 포맷일 경우 자동으로 NV12로 변환 후 복사합니다.
|
||||
/// Reads the next video frame and copies its NV12 format Y and UV channel data separately.
|
||||
/// If the source format is NV12 family, CPU color conversion (sws_scale) is bypassed
|
||||
/// and memory is copied directly for maximum performance.
|
||||
/// Other formats are automatically converted to NV12 before copying.
|
||||
/// </summary>
|
||||
// ReSharper disable once InconsistentNaming
|
||||
private bool TryReadNV12Frame(
|
||||
@@ -556,7 +558,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new Exception($"디코딩 중 오류 발생: {GetFFmpegErrorString(ret)}");
|
||||
throw new Exception($"Error during decoding: {GetFFmpegErrorString(ret)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -587,7 +589,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
int h = frame->height;
|
||||
int uvH = h / 2;
|
||||
|
||||
// Y plane 복사
|
||||
// Copy Y plane
|
||||
for (int y = 0; y < h; y++)
|
||||
{
|
||||
Buffer.MemoryCopy(
|
||||
@@ -597,7 +599,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
);
|
||||
}
|
||||
|
||||
// UV plane 복사
|
||||
// Copy UV plane
|
||||
for (int y = 0; y < uvH; y++)
|
||||
{
|
||||
Buffer.MemoryCopy(
|
||||
@@ -625,7 +627,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
|
||||
if (yBuffer.Length < w * h || uvBuffer.Length < w * uvH)
|
||||
{
|
||||
throw new ArgumentException("버퍼 크기가 비디오 NV12 평면 해상도보다 작습니다.");
|
||||
throw new ArgumentException("Buffer size is smaller than video NV12 plane resolution.");
|
||||
}
|
||||
|
||||
return TryReadNV12Frame(
|
||||
@@ -636,10 +638,9 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 지정한 개수만큼 프레임을 디코딩 후 바로 버립니다. (빠른 전방 탐색용)
|
||||
/// Decodes and discards a specified number of frames immediately (for fast forward skipping).
|
||||
/// </summary>
|
||||
/// <param name="count">버릴 프레임 개수</param>
|
||||
/// <returns>실제로 건너뛴 프레임 개수</returns>
|
||||
/// <param name="count">Number of frames to discard.</param>
|
||||
private void SkipFrames(int count)
|
||||
{
|
||||
lock (_lock)
|
||||
@@ -682,16 +683,16 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new Exception($"프레임 건너뛰기 중 오류 발생: {GetFFmpegErrorString(ret)}");
|
||||
throw new Exception($"Error skipping frames: {GetFFmpegErrorString(ret)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 특정 재생 시간(초)으로 탐색(Seek)합니다.
|
||||
/// Seeks to a specific playback time in seconds.
|
||||
/// </summary>
|
||||
/// <param name="timeSeconds">탐색할 시간 (초)</param>
|
||||
/// <param name="timeSeconds">Target time in seconds to seek to.</param>
|
||||
public void SeekToTime(double timeSeconds)
|
||||
{
|
||||
lock (_lock)
|
||||
@@ -701,7 +702,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
if (timeSeconds < 0) timeSeconds = 0;
|
||||
double targetFrameIndex = timeSeconds * FPS;
|
||||
|
||||
// 이미 해당 인덱스이고 매우 인접한 미래라면 seek 대신 skip 처리 (I-프레임 찾기 오버헤드 방지)
|
||||
// If already at target index or very close future, skip frames instead of seek to avoid I-frame overhead
|
||||
bool useSkip = targetFrameIndex >= _currentFrameIndex && targetFrameIndex - _currentFrameIndex < 60;
|
||||
if (useSkip)
|
||||
{
|
||||
@@ -719,19 +720,19 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
int ret = ffmpeg.av_seek_frame(_pFormatContext, _videoStreamIndex, targetPts, ffmpeg.AVSEEK_FLAG_BACKWARD);
|
||||
if (ret < 0)
|
||||
{
|
||||
// 실패 시 파일 처음부터 탐색 시도
|
||||
// Fallback to start of file on seek failure
|
||||
ret = ffmpeg.av_seek_frame(_pFormatContext, _videoStreamIndex, 0, ffmpeg.AVSEEK_FLAG_BACKWARD);
|
||||
if (ret < 0)
|
||||
{
|
||||
throw new Exception($"Seek 실패: {GetFFmpegErrorString(ret)}");
|
||||
throw new Exception($"Seek failed: {GetFFmpegErrorString(ret)}");
|
||||
}
|
||||
}
|
||||
|
||||
// 디코더 내부 캐시 초기화
|
||||
// Flush decoder internal cache
|
||||
ffmpeg.avcodec_flush_buffers(_pCodecContext);
|
||||
_isDraining = false;
|
||||
|
||||
// 지정된 시간 이후의 첫 번째 프레임을 찾을 때까지 디코딩을 진행하며 버립니다.
|
||||
// Decode and discard frames until the first frame past the specified time is found
|
||||
while (true)
|
||||
{
|
||||
ret = ffmpeg.avcodec_receive_frame(_pCodecContext, _pDecodedFrame);
|
||||
@@ -739,10 +740,10 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
{
|
||||
double currentPts = GetTimestampAndUpdateIndex(_pDecodedFrame);
|
||||
|
||||
// 플로팅 포인트 오차 감안하여 하프 프레임 제외한 시간부터 체크
|
||||
// Check from half frame earlier to account for floating point errors
|
||||
if (currentPts >= timeSeconds - (0.5 / FPS))
|
||||
{
|
||||
// 매칭되는 첫 프레임을 캐시에 임시 저장하여 다음 TryReadFrame에서 소모하도록 유도
|
||||
// Cache the first matching frame so it will be consumed by the next TryReadFrame call
|
||||
ffmpeg.av_frame_move_ref(_pCachedFrame, _pDecodedFrame);
|
||||
_hasCachedFrame = true;
|
||||
break;
|
||||
@@ -768,16 +769,16 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new Exception($"Seek 디코딩 중 오류 발생: {GetFFmpegErrorString(ret)}");
|
||||
throw new Exception($"Error during seek decoding: {GetFFmpegErrorString(ret)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 특정 프레임 인덱스로 탐색(Seek)합니다.
|
||||
/// Seeks to a specific frame index.
|
||||
/// </summary>
|
||||
/// <param name="frameIndex">탐색할 프레임 인덱스</param>
|
||||
/// <param name="frameIndex">Target frame index to seek to.</param>
|
||||
public void SeekToFrame(long frameIndex)
|
||||
{
|
||||
SeekToTime(frameIndex / FPS);
|
||||
@@ -789,9 +790,9 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
byte* buffer = stackalloc byte[(int)bufferSize];
|
||||
if (ffmpeg.av_strerror(errnum, buffer, bufferSize) >= 0)
|
||||
{
|
||||
return System.Runtime.InteropServices.Marshal.PtrToStringAnsi((IntPtr)buffer) ?? $"알 수 없는 에러 (Code: {errnum})";
|
||||
return System.Runtime.InteropServices.Marshal.PtrToStringAnsi((IntPtr)buffer) ?? $"Unknown error (Code: {errnum})";
|
||||
}
|
||||
return $"알 수 없는 에러 (Code: {errnum})";
|
||||
return $"Unknown error (Code: {errnum})";
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
||||
+566
-565
File diff suppressed because it is too large.
Load diff
+11
-10
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using ADOFAIRenderer.UI;
|
||||
using ADOFAIRenderer.Util.FFmpeg;
|
||||
using MelonLoader;
|
||||
using UnityEngine;
|
||||
@@ -70,14 +71,14 @@ namespace ADOFAIRenderer.Util
|
||||
|
||||
if (!File.Exists(videoPath))
|
||||
{
|
||||
Melon<RenderMod>.Logger.Warning($"[FFmpegVideoBGPlayer] 비디오 파일이 존재하지 않습니다: {videoPath}");
|
||||
Melon<RenderMod>.Logger.Warning($"[FFmpegVideoBGPlayer] Video file does not exist: {videoPath}");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_decoder = new FFmpegNativeDecoder(videoPath);
|
||||
_computeShader = RenderMod.ColorConversionShader;
|
||||
_computeShader = ModAssets.ColorConversionShader;
|
||||
|
||||
var w = _decoder.Width;
|
||||
var h = _decoder.Height;
|
||||
@@ -101,7 +102,7 @@ namespace ADOFAIRenderer.Util
|
||||
|
||||
_gpuMode = GPUMode.NV12;
|
||||
gpuInitSuccess = true;
|
||||
Melon<RenderMod>.Logger.Msg($"[FFmpegVideoBGPlayer] Compute Shader NV12 모드 초기화 완료");
|
||||
Melon<RenderMod>.Logger.Msg("[FFmpegVideoBGPlayer] Initialized Compute Shader NV12 mode successfully");
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -124,7 +125,7 @@ namespace ADOFAIRenderer.Util
|
||||
|
||||
_gpuMode = GPUMode.YUV420P;
|
||||
gpuInitSuccess = true;
|
||||
Melon<RenderMod>.Logger.Msg($"[FFmpegVideoBGPlayer] Compute Shader YUV420P 모드 초기화 완료");
|
||||
Melon<RenderMod>.Logger.Msg("[FFmpegVideoBGPlayer] Initialized Compute Shader YUV420P mode successfully");
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -133,7 +134,7 @@ namespace ADOFAIRenderer.Util
|
||||
|
||||
break;
|
||||
default:
|
||||
Melon<RenderMod>.Logger.Msg("[FFmpegVideoBGPlayer] 색공간 변환에 Compute Shader를 사용할 수 없습니다.");
|
||||
Melon<RenderMod>.Logger.Msg("[FFmpegVideoBGPlayer] Cannot use Compute Shader for colorspace conversion.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -151,18 +152,18 @@ namespace ADOFAIRenderer.Util
|
||||
_textureRGBA = new Texture2D(w, h, TextureFormat.RGBA32, false);
|
||||
ConfigureTexture(_textureRGBA);
|
||||
_gpuMode = GPUMode.None;
|
||||
Melon<RenderMod>.Logger.Warning($"[FFmpegVideoBGPlayer] GPU 가속 미지원 또는 로드 실패로 CPU 폴백(RGBA)으로 진행합니다.");
|
||||
Melon<RenderMod>.Logger.Warning("[FFmpegVideoBGPlayer] GPU acceleration unsupported or failed to load, falling back to CPU (RGBA).");
|
||||
}
|
||||
|
||||
_currentFrameTimestamp = 0.0;
|
||||
_nextFrameTimestamp = 0.0;
|
||||
_isInitialized = true;
|
||||
|
||||
Melon<RenderMod>.Logger.Msg($"[FFmpegVideoBGPlayer] 초기화 성공 - 해상도: {w}x{h}, FPS: {_decoder.FPS:F2}");
|
||||
Melon<RenderMod>.Logger.Msg($"[FFmpegVideoBGPlayer] Successfully initialized - Resolution: {w}x{h}, FPS: {_decoder.FPS:F2}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Error($"[FFmpegVideoBGPlayer] 초기화 중 에러 발생: {ex}");
|
||||
Melon<RenderMod>.Logger.Error($"[FFmpegVideoBGPlayer] Error during initialization: {ex}");
|
||||
CleanUp();
|
||||
}
|
||||
}
|
||||
@@ -283,7 +284,7 @@ namespace ADOFAIRenderer.Util
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Error($"[FFmpegVideoBGPlayer] Update 재생 중 에러 발생: {ex}");
|
||||
Melon<RenderMod>.Logger.Error($"[FFmpegVideoBGPlayer] Error during playback update: {ex}");
|
||||
_isInitialized = false;
|
||||
}
|
||||
}
|
||||
@@ -395,7 +396,7 @@ namespace ADOFAIRenderer.Util
|
||||
{
|
||||
_decoder.Dispose();
|
||||
_decoder = null;
|
||||
Melon<RenderMod>.Logger.Msg("[FFmpegVideoBGPlayer] 디코더 해제 완료.");
|
||||
Melon<RenderMod>.Logger.Msg("[FFmpegVideoBGPlayer] Decoder successfully disposed.");
|
||||
}
|
||||
|
||||
if (_textureRGBA != null)
|
||||
|
||||
@@ -0,0 +1,615 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MelonLoader;
|
||||
|
||||
namespace ADOFAIRenderer.Util;
|
||||
|
||||
internal static class FolderDialogUtil
|
||||
{
|
||||
private static int _isDialogOpen;
|
||||
|
||||
public static void OpenFolderBrowser(string initialDirectory, Action<string?> onSelected)
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref _isDialogOpen, 1, 0) != 0)
|
||||
{
|
||||
MelonLogger.Msg("[FolderDialogUtil] Folder dialog is already open.");
|
||||
return;
|
||||
}
|
||||
|
||||
var syncContext = SynchronizationContext.Current;
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
var ownerHandle = GetGameWindowHandle();
|
||||
|
||||
var thread = new Thread(() =>
|
||||
{
|
||||
string? selected = null;
|
||||
int oleHr = -1;
|
||||
try
|
||||
{
|
||||
oleHr = OleInitialize(IntPtr.Zero);
|
||||
selected = OpenWindowsFolderBrowser(initialDirectory, ownerHandle);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Warning($"[FolderDialogUtil] Windows folder dialog error: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (oleHr >= 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
OleUninitialize();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore uninitialize error
|
||||
}
|
||||
}
|
||||
Interlocked.Exchange(ref _isDialogOpen, 0);
|
||||
}
|
||||
|
||||
DispatchResult(syncContext, onSelected, selected);
|
||||
})
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "FolderDialog-Win"
|
||||
};
|
||||
thread.SetApartmentState(ApartmentState.STA);
|
||||
thread.Start();
|
||||
}
|
||||
else
|
||||
{
|
||||
Task.Run(() =>
|
||||
{
|
||||
string? selected = null;
|
||||
try
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
{
|
||||
selected = OpenMacFolderBrowser(initialDirectory);
|
||||
}
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
{
|
||||
selected = OpenLinuxFolderBrowser(initialDirectory);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Warning($"[FolderDialogUtil] Failed to open folder browser: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Exchange(ref _isDialogOpen, 0);
|
||||
}
|
||||
|
||||
DispatchResult(syncContext, onSelected, selected);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void DispatchResult(SynchronizationContext? syncContext, Action<string?> onSelected, string? selected)
|
||||
{
|
||||
if (syncContext != null)
|
||||
{
|
||||
syncContext.Post(_ =>
|
||||
{
|
||||
try
|
||||
{
|
||||
onSelected(selected);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Warning($"[FolderDialogUtil] Error in folder selected callback: {ex.Message}");
|
||||
}
|
||||
}, null);
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
onSelected(selected);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Warning($"[FolderDialogUtil] Error in folder selected callback: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#region Windows Implementation
|
||||
|
||||
private static readonly Guid CLSID_FileOpenDialog = new("DC1C5A9C-E88A-4DDE-A5A1-60F82A20AEF7");
|
||||
private static readonly Guid IID_IFileOpenDialog = new("d57c7288-d4ad-4768-be02-9d969532d960");
|
||||
private static readonly Guid IID_IShellItem = new("43826D1E-E718-42EE-BC55-A1E261C37BFE");
|
||||
private const int ERROR_CANCELLED_HR = unchecked((int)0x800704C7);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr GetActiveWindow();
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr GetForegroundWindow();
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
|
||||
|
||||
private static IntPtr GetGameWindowHandle()
|
||||
{
|
||||
try
|
||||
{
|
||||
var active = GetActiveWindow();
|
||||
if (active != IntPtr.Zero) return active;
|
||||
|
||||
var currentPid = (uint)Process.GetCurrentProcess().Id;
|
||||
var fg = GetForegroundWindow();
|
||||
if (fg != IntPtr.Zero)
|
||||
{
|
||||
GetWindowThreadProcessId(fg, out var fgPid);
|
||||
if (fgPid == currentPid)
|
||||
{
|
||||
return fg;
|
||||
}
|
||||
}
|
||||
|
||||
var mainHandle = Process.GetCurrentProcess().MainWindowHandle;
|
||||
if (mainHandle != IntPtr.Zero)
|
||||
{
|
||||
return mainHandle;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore failure to detect window handle
|
||||
}
|
||||
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
private static string? OpenWindowsFolderBrowser(string initialDirectory, IntPtr ownerHandle)
|
||||
{
|
||||
try
|
||||
{
|
||||
return OpenWindowsFolderBrowserModern(initialDirectory, ownerHandle);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Warning($"[FolderDialogUtil] Modern folder dialog failed ({ex.Message}), falling back to legacy dialog.");
|
||||
return OpenWindowsFolderBrowserLegacy(initialDirectory, ownerHandle);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? OpenWindowsFolderBrowserModern(string initialDirectory, IntPtr ownerHandle)
|
||||
{
|
||||
IFileOpenDialog? dialog = null;
|
||||
|
||||
// 1. Try native CoCreateInstance first (works on Mono and .NET without class registration)
|
||||
var clsid = CLSID_FileOpenDialog;
|
||||
var iid = IID_IFileOpenDialog;
|
||||
int createHr = CoCreateInstance(ref clsid, IntPtr.Zero, 1 /* CLSCTX_INPROC_SERVER */, ref iid, out dialog);
|
||||
|
||||
if (createHr != 0 || dialog == null)
|
||||
{
|
||||
// 2. Fallback to Activator via Type.GetTypeFromCLSID
|
||||
var comType = Type.GetTypeFromCLSID(CLSID_FileOpenDialog);
|
||||
if (comType != null)
|
||||
{
|
||||
var instance = Activator.CreateInstance(comType);
|
||||
dialog = instance as IFileOpenDialog;
|
||||
}
|
||||
}
|
||||
|
||||
if (dialog == null)
|
||||
{
|
||||
throw new COMException("Failed to instantiate IFileOpenDialog via COM.", createHr);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
dialog.SetOptions(FOS.FOS_PICKFOLDERS | FOS.FOS_FORCEFILESYSTEM | FOS.FOS_PATHMUSTEXIST);
|
||||
dialog.SetTitle("Select Output Directory");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(initialDirectory))
|
||||
{
|
||||
try
|
||||
{
|
||||
var normalized = Path.GetFullPath(initialDirectory).TrimEnd('\\', '/');
|
||||
if (Directory.Exists(normalized))
|
||||
{
|
||||
var shellItemGuid = IID_IShellItem;
|
||||
int itemHr = SHCreateItemFromParsingName(normalized, IntPtr.Zero, ref shellItemGuid, out var initialItem);
|
||||
if (itemHr == 0 && initialItem != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
dialog.SetFolder(initialItem);
|
||||
dialog.SetDefaultFolder(initialItem);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.ReleaseComObject(initialItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore failure to set initial folder
|
||||
}
|
||||
}
|
||||
|
||||
int hr = dialog.Show(ownerHandle);
|
||||
if (hr != 0 && hr != ERROR_CANCELLED_HR && ownerHandle != IntPtr.Zero)
|
||||
{
|
||||
// If Show failed with ownerHandle (e.g. cross-thread or invalid handle), retry without owner
|
||||
MelonLogger.Msg($"[FolderDialogUtil] dialog.Show(ownerHandle) returned 0x{hr:X8}, retrying with IntPtr.Zero.");
|
||||
hr = dialog.Show(IntPtr.Zero);
|
||||
}
|
||||
|
||||
if (hr == ERROR_CANCELLED_HR)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hr != 0)
|
||||
{
|
||||
throw new COMException("IFileOpenDialog.Show failed", hr);
|
||||
}
|
||||
|
||||
dialog.GetResult(out var resultItem);
|
||||
if (resultItem == null) return null;
|
||||
|
||||
try
|
||||
{
|
||||
if (resultItem.GetDisplayName(SIGDN.SIGDN_FILESYSPATH, out var pszPath) == 0 && pszPath != IntPtr.Zero)
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = Marshal.PtrToStringUni(pszPath);
|
||||
return !string.IsNullOrEmpty(path) && Directory.Exists(path) ? path : null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeCoTaskMem(pszPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.ReleaseComObject(resultItem);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.ReleaseComObject(dialog);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? OpenWindowsFolderBrowserLegacy(string initialDirectory, IntPtr ownerHandle)
|
||||
{
|
||||
IntPtr pszDisplayName = Marshal.AllocHGlobal(260 * sizeof(char));
|
||||
IntPtr initialDirPtr = IntPtr.Zero;
|
||||
BrowseCallbackProc? callback = null;
|
||||
IntPtr pidl = IntPtr.Zero;
|
||||
|
||||
try
|
||||
{
|
||||
var bi = new BROWSEINFO
|
||||
{
|
||||
hwndOwner = ownerHandle,
|
||||
pidlRoot = IntPtr.Zero,
|
||||
pszDisplayName = pszDisplayName,
|
||||
lpszTitle = "Select Output Directory",
|
||||
ulFlags = BIF_RETURNONLYFSDIRS | BIF_NEWDIALOGSTYLE | BIF_EDITBOX
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(initialDirectory))
|
||||
{
|
||||
try
|
||||
{
|
||||
var normalized = Path.GetFullPath(initialDirectory).TrimEnd('\\', '/');
|
||||
if (Directory.Exists(normalized))
|
||||
{
|
||||
initialDirPtr = Marshal.StringToHGlobalUni(normalized);
|
||||
callback = (hwnd, uMsg, lParam, lpData) =>
|
||||
{
|
||||
if (uMsg == BFFM_INITIALIZED && lpData != IntPtr.Zero)
|
||||
{
|
||||
SendMessage(hwnd, BFFM_SETSELECTIONW, (IntPtr)1, lpData);
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
bi.lpfn = callback;
|
||||
bi.lParam = initialDirPtr;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore failure to prepare initial directory
|
||||
}
|
||||
}
|
||||
|
||||
pidl = SHBrowseForFolderW(ref bi);
|
||||
if (pidl == IntPtr.Zero && ownerHandle != IntPtr.Zero)
|
||||
{
|
||||
// If it returned null with ownerHandle, check if retry with IntPtr.Zero is appropriate
|
||||
// But note: cancelling also returns Zero. We do not redundantly re-prompt if cancelled.
|
||||
}
|
||||
|
||||
if (pidl != IntPtr.Zero)
|
||||
{
|
||||
var sb = new StringBuilder(260);
|
||||
if (SHGetPathFromIDListW(pidl, sb))
|
||||
{
|
||||
var path = sb.ToString();
|
||||
return !string.IsNullOrEmpty(path) && Directory.Exists(path) ? path : null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (pidl != IntPtr.Zero)
|
||||
{
|
||||
Marshal.FreeCoTaskMem(pidl);
|
||||
}
|
||||
if (initialDirPtr != IntPtr.Zero)
|
||||
{
|
||||
Marshal.FreeHGlobal(initialDirPtr);
|
||||
}
|
||||
if (pszDisplayName != IntPtr.Zero)
|
||||
{
|
||||
Marshal.FreeHGlobal(pszDisplayName);
|
||||
}
|
||||
GC.KeepAlive(callback);
|
||||
}
|
||||
}
|
||||
|
||||
#region COM & Win32 Interop Definitions
|
||||
|
||||
[Flags]
|
||||
private enum FOS : uint
|
||||
{
|
||||
FOS_NOCHANGEDIR = 0x00000008,
|
||||
FOS_PICKFOLDERS = 0x00000020,
|
||||
FOS_FORCEFILESYSTEM = 0x00000040,
|
||||
FOS_PATHMUSTEXIST = 0x00000800,
|
||||
FOS_FILEMUSTEXIST = 0x00001000,
|
||||
}
|
||||
|
||||
private enum SIGDN : uint
|
||||
{
|
||||
SIGDN_FILESYSPATH = 0x80058000
|
||||
}
|
||||
|
||||
[ComImport]
|
||||
[Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE")]
|
||||
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
private interface IShellItem
|
||||
{
|
||||
void BindToHandler(IntPtr pbc, [In] ref Guid bhid, [In] ref Guid riid, out IntPtr ppv);
|
||||
void GetParent(out IShellItem ppsi);
|
||||
[PreserveSig]
|
||||
int GetDisplayName(SIGDN sigdnName, out IntPtr ppszName);
|
||||
void GetAttributes(uint sfgaoMask, out uint psfgaoAttribs);
|
||||
void Compare(IShellItem psi, uint hint, out int piOrder);
|
||||
}
|
||||
|
||||
[ComImport]
|
||||
[Guid("d57c7288-d4ad-4768-be02-9d969532d960")]
|
||||
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
private interface IFileOpenDialog
|
||||
{
|
||||
[PreserveSig]
|
||||
int Show(IntPtr parent);
|
||||
void SetFileTypes(uint cFileTypes, IntPtr rgFilterSpec);
|
||||
void SetFileTypeIndex(uint iFileType);
|
||||
void GetFileTypeIndex(out uint piFileType);
|
||||
void Advise(IntPtr pfde, out uint pdwCookie);
|
||||
void Unadvise(uint dwCookie);
|
||||
void SetOptions(FOS fos);
|
||||
void GetOptions(out FOS pfos);
|
||||
void SetDefaultFolder(IShellItem psi);
|
||||
void SetFolder(IShellItem psi);
|
||||
void GetFolder(out IShellItem ppsi);
|
||||
void GetCurrentSelection(out IShellItem ppsi);
|
||||
void SetFileName([MarshalAs(UnmanagedType.LPWStr)] string pszName);
|
||||
void GetFileName([MarshalAs(UnmanagedType.LPWStr)] out string pszName);
|
||||
void SetTitle([MarshalAs(UnmanagedType.LPWStr)] string pszTitle);
|
||||
void SetOkButtonLabel([MarshalAs(UnmanagedType.LPWStr)] string pszText);
|
||||
void SetFileNameLabel([MarshalAs(UnmanagedType.LPWStr)] string pszLabel);
|
||||
void GetResult(out IShellItem ppsi);
|
||||
void AddPlace(IShellItem psi, int fdap);
|
||||
void SetDefaultExtension([MarshalAs(UnmanagedType.LPWStr)] string pszDefaultExtension);
|
||||
void Close(int hr);
|
||||
void SetClientGuid(ref Guid guid);
|
||||
void ClearClientData();
|
||||
void SetFilter(IntPtr pFilter);
|
||||
void GetResults(out IntPtr ppenum);
|
||||
void GetSelectedItems(out IntPtr ppsai);
|
||||
}
|
||||
|
||||
[DllImport("ole32.dll", ExactSpelling = true)]
|
||||
private static extern int OleInitialize(IntPtr pvReserved);
|
||||
|
||||
[DllImport("ole32.dll", ExactSpelling = true)]
|
||||
private static extern void OleUninitialize();
|
||||
|
||||
[DllImport("ole32.dll", ExactSpelling = true)]
|
||||
private static extern int CoCreateInstance(
|
||||
[In] ref Guid rclsid,
|
||||
IntPtr pUnkOuter,
|
||||
uint dwClsContext,
|
||||
[In] ref Guid riid,
|
||||
[Out, MarshalAs(UnmanagedType.Interface)] out IFileOpenDialog ppv);
|
||||
|
||||
[DllImport("shell32.dll", CharSet = CharSet.Unicode, PreserveSig = true)]
|
||||
private static extern int SHCreateItemFromParsingName(
|
||||
[MarshalAs(UnmanagedType.LPWStr)] string pszPath,
|
||||
IntPtr pbc,
|
||||
[In] ref Guid riid,
|
||||
[Out, MarshalAs(UnmanagedType.Interface)] out IShellItem ppv);
|
||||
|
||||
private const uint BIF_RETURNONLYFSDIRS = 0x0001;
|
||||
private const uint BIF_NEWDIALOGSTYLE = 0x0040;
|
||||
private const uint BIF_EDITBOX = 0x0010;
|
||||
private const uint BFFM_INITIALIZED = 1;
|
||||
private const uint BFFM_SETSELECTIONW = 0x0400 + 103;
|
||||
|
||||
private delegate int BrowseCallbackProc(IntPtr hwnd, uint uMsg, IntPtr lParam, IntPtr lpData);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
private struct BROWSEINFO
|
||||
{
|
||||
public IntPtr hwndOwner;
|
||||
public IntPtr pidlRoot;
|
||||
public IntPtr pszDisplayName;
|
||||
public string lpszTitle;
|
||||
public uint ulFlags;
|
||||
public BrowseCallbackProc? lpfn;
|
||||
public IntPtr lParam;
|
||||
public int iImage;
|
||||
}
|
||||
|
||||
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern IntPtr SHBrowseForFolderW(ref BROWSEINFO lpbi);
|
||||
|
||||
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool SHGetPathFromIDListW(IntPtr pidl, StringBuilder pszPath);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern IntPtr SendMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
#endregion
|
||||
|
||||
#endregion
|
||||
|
||||
#region macOS Implementation
|
||||
|
||||
private static string? OpenMacFolderBrowser(string initialDirectory)
|
||||
{
|
||||
try
|
||||
{
|
||||
string script;
|
||||
if (!string.IsNullOrEmpty(initialDirectory) && Directory.Exists(initialDirectory))
|
||||
{
|
||||
var escaped = initialDirectory.Replace("\\", "\\\\").Replace("\"", "\\\"");
|
||||
script = $"POSIX path of (choose folder with prompt \"Select Output Directory\" default location (POSIX file \"{escaped}\"))";
|
||||
}
|
||||
else
|
||||
{
|
||||
script = "POSIX path of (choose folder with prompt \"Select Output Directory\")";
|
||||
}
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "osascript",
|
||||
CreateNoWindow = true,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
WindowStyle = ProcessWindowStyle.Hidden
|
||||
};
|
||||
startInfo.ArgumentList.Add("-e");
|
||||
startInfo.ArgumentList.Add(script);
|
||||
|
||||
using var process = Process.Start(startInfo);
|
||||
if (process == null) return null;
|
||||
|
||||
var output = process.StandardOutput.ReadToEnd().Trim();
|
||||
process.WaitForExit();
|
||||
|
||||
return !string.IsNullOrEmpty(output) && Directory.Exists(output) ? output : null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Warning($"[FolderDialogUtil] Mac folder dialog error: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Linux Implementation
|
||||
|
||||
private static string? OpenLinuxFolderBrowser(string initialDirectory)
|
||||
{
|
||||
try
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "zenity",
|
||||
CreateNoWindow = true,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
WindowStyle = ProcessWindowStyle.Hidden
|
||||
};
|
||||
startInfo.ArgumentList.Add("--file-selection");
|
||||
startInfo.ArgumentList.Add("--directory");
|
||||
startInfo.ArgumentList.Add("--title=Select Output Directory");
|
||||
if (!string.IsNullOrEmpty(initialDirectory) && Directory.Exists(initialDirectory))
|
||||
{
|
||||
startInfo.ArgumentList.Add($"--filename={initialDirectory}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var process = Process.Start(startInfo);
|
||||
if (process != null)
|
||||
{
|
||||
var output = process.StandardOutput.ReadToEnd().Trim();
|
||||
process.WaitForExit();
|
||||
|
||||
if (process.ExitCode == 0 && !string.IsNullOrEmpty(output) && Directory.Exists(output))
|
||||
{
|
||||
return output;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fallback to kdialog if zenity fails to start
|
||||
}
|
||||
|
||||
var kdialogInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "kdialog",
|
||||
CreateNoWindow = true,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
WindowStyle = ProcessWindowStyle.Hidden
|
||||
};
|
||||
kdialogInfo.ArgumentList.Add("--getexistingdirectory");
|
||||
if (!string.IsNullOrEmpty(initialDirectory) && Directory.Exists(initialDirectory))
|
||||
{
|
||||
kdialogInfo.ArgumentList.Add(initialDirectory);
|
||||
}
|
||||
kdialogInfo.ArgumentList.Add("--title");
|
||||
kdialogInfo.ArgumentList.Add("Select Output Directory");
|
||||
|
||||
using var kdialogProcess = Process.Start(kdialogInfo);
|
||||
if (kdialogProcess == null) return null;
|
||||
|
||||
var koutput = kdialogProcess.StandardOutput.ReadToEnd().Trim();
|
||||
kdialogProcess.WaitForExit();
|
||||
|
||||
return !string.IsNullOrEmpty(koutput) && Directory.Exists(koutput) ? koutput : null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Warning($"[FolderDialogUtil] Linux folder dialog error: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
+14
-13
@@ -3,6 +3,7 @@ using System.Buffers;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using ADOFAIRenderer.Core;
|
||||
using ADOFAIRenderer.UI;
|
||||
using FFmpeg.AutoGen;
|
||||
using MelonLoader;
|
||||
using Unity.Collections;
|
||||
@@ -70,14 +71,14 @@ namespace ADOFAIRenderer.Util
|
||||
state.IsInFlight = true;
|
||||
}
|
||||
|
||||
// GPU 컬러 변환 활성화 여부 검사
|
||||
bool useGPUConversion = RenderMod.ColorConversionShader;
|
||||
// Check if GPU color conversion is enabled
|
||||
bool useGPUConversion = ModAssets.ColorConversionShader;
|
||||
AVPixelFormat expectedFmt = AVPixelFormat.AV_PIX_FMT_RGBA;
|
||||
|
||||
if (useGPUConversion)
|
||||
{
|
||||
expectedFmt = _targetFormat;
|
||||
// Compute Shader는 NV12와 YUV420P만 지원하므로 그 외 포맷은 CPU 폴백 처리
|
||||
// Compute Shader only supports NV12 and YUV420P; fallback to CPU for other formats
|
||||
if (expectedFmt != AVPixelFormat.AV_PIX_FMT_NV12 && expectedFmt != AVPixelFormat.AV_PIX_FMT_YUV420P)
|
||||
{
|
||||
useGPUConversion = false;
|
||||
@@ -87,31 +88,31 @@ namespace ADOFAIRenderer.Util
|
||||
var bufferSize = texture.width * texture.height * 4; // RGBA = 4 channel
|
||||
state.Setup(currentFrame, bufferSize, useGPUConversion, expectedFmt, texture.width, texture.height);
|
||||
|
||||
if (useGPUConversion && state.GPUBuffer != null && RenderMod.ColorConversionShader)
|
||||
if (useGPUConversion && state.GPUBuffer != null && ModAssets.ColorConversionShader)
|
||||
{
|
||||
int kernelIndex = -1;
|
||||
if (expectedFmt == AVPixelFormat.AV_PIX_FMT_NV12)
|
||||
{
|
||||
kernelIndex = RenderMod.ColorConversionShader.FindKernel("CSMain_NV12");
|
||||
kernelIndex = ModAssets.ColorConversionShader.FindKernel("CSMain_NV12");
|
||||
}
|
||||
else if (expectedFmt == AVPixelFormat.AV_PIX_FMT_YUV420P)
|
||||
{
|
||||
kernelIndex = RenderMod.ColorConversionShader.FindKernel("CSMain_YUV420P");
|
||||
kernelIndex = ModAssets.ColorConversionShader.FindKernel("CSMain_YUV420P");
|
||||
}
|
||||
|
||||
if (kernelIndex >= 0)
|
||||
{
|
||||
RenderMod.ColorConversionShader.SetTexture(kernelIndex, SourceTexture, texture);
|
||||
RenderMod.ColorConversionShader.SetBuffer(kernelIndex, DestinationBuffer, state.GPUBuffer);
|
||||
RenderMod.ColorConversionShader.SetInt(Width, texture.width);
|
||||
RenderMod.ColorConversionShader.SetInt(Height, texture.height);
|
||||
ModAssets.ColorConversionShader.SetTexture(kernelIndex, SourceTexture, texture);
|
||||
ModAssets.ColorConversionShader.SetBuffer(kernelIndex, DestinationBuffer, state.GPUBuffer);
|
||||
ModAssets.ColorConversionShader.SetInt(Width, texture.width);
|
||||
ModAssets.ColorConversionShader.SetInt(Height, texture.height);
|
||||
|
||||
var threadGroupsX = expectedFmt == AVPixelFormat.AV_PIX_FMT_NV12
|
||||
? (texture.width + 31) / 32
|
||||
: (texture.width + 63) / 64;
|
||||
var threadGroupsY = (texture.height + 15) / 16;
|
||||
|
||||
RenderMod.ColorConversionShader.Dispatch(kernelIndex, threadGroupsX, threadGroupsY, 1);
|
||||
ModAssets.ColorConversionShader.Dispatch(kernelIndex, threadGroupsX, threadGroupsY, 1);
|
||||
|
||||
AsyncGPUReadback.Request(
|
||||
state.GPUBuffer,
|
||||
@@ -120,7 +121,7 @@ namespace ADOFAIRenderer.Util
|
||||
}
|
||||
else
|
||||
{
|
||||
// 커널을 못 찾은 경우 기존 RGBA 텍스처 백업 기법으로 폴백
|
||||
// If kernel is not found, fallback to standard RGBA texture readback
|
||||
AsyncGPUReadback.RequestIntoNativeArray(
|
||||
ref state.NativeBuffer,
|
||||
texture,
|
||||
@@ -189,7 +190,7 @@ namespace ADOFAIRenderer.Util
|
||||
if (state.IsGPUConversion)
|
||||
{
|
||||
var data = request.GetData<byte>();
|
||||
// 비동기 요청 버퍼 데이터를 지속성(NativeBuffer) 메모리로 복사
|
||||
// Copy async request buffer data to persistent NativeBuffer memory
|
||||
NativeArray<byte>.Copy(data, state.NativeBuffer, data.Length);
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ namespace ADOFAIRenderer.Util
|
||||
@"^NVIDIA GeForce [RG]TX [0-9]+(?> SUPER| Ti(?> SUPER)?)?(?> [0-9]+GB)?(?> Laptop GPU)?(?>/PCIe/SSE2)?$";
|
||||
|
||||
private const string IntelPattern =
|
||||
@"^Intel(?>\(R\))? (?>U?HD Graphics [0-9]+$|Arc\(TM\) [A-C][0-9]+ Graphics)$";
|
||||
@"^Intel(?>\(R\))? (?>U?HD Graphics [0-9]+|Arc\(TM\) [A-C][0-9]+ Graphics)(?> \([^)]+\))?$";
|
||||
|
||||
// ReSharper disable once InconsistentNaming
|
||||
private const string AMDPattern =
|
||||
|
||||
+11
-11
@@ -48,7 +48,7 @@ namespace ADOFAIRenderer.Util
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Warning($"[OSNotification] 알림을 보내지 못했습니다: {ex.Message}");
|
||||
MelonLogger.Warning($"[OSNotification] Failed to send notification: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ namespace ADOFAIRenderer.Util
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Warning($"[OSNotification] Windows 알림 실패: {ex.Message}");
|
||||
MelonLogger.Warning($"[OSNotification] Windows notification failed: {ex.Message}");
|
||||
}
|
||||
})
|
||||
{
|
||||
@@ -110,7 +110,7 @@ namespace ADOFAIRenderer.Util
|
||||
$"<text>{xmlTitle}</text><text>{xmlMessage}</text>" +
|
||||
"</binding></visual>" +
|
||||
"<actions>" +
|
||||
"<action content=\"렌더 폴더 열기\" arguments=\"adofai-renderer://\" activationType=\"protocol\"/>" +
|
||||
"<action content=\"Open Render Folder\" arguments=\"adofai-renderer://\" activationType=\"protocol\"/>" +
|
||||
"</actions></toast>";
|
||||
|
||||
psScript =
|
||||
@@ -165,7 +165,7 @@ namespace ADOFAIRenderer.Util
|
||||
{
|
||||
string stderr = process.StandardError.ReadToEnd().Trim();
|
||||
if (!string.IsNullOrEmpty(stderr))
|
||||
MelonLogger.Warning($"[OSNotification] Toast 실패 (fallback 사용): {stderr}");
|
||||
MelonLogger.Warning($"[OSNotification] Toast failed (using fallback): {stderr}");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -173,7 +173,7 @@ namespace ADOFAIRenderer.Util
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Warning($"[OSNotification] Toast 실패 (fallback 사용): {ex.Message}");
|
||||
MelonLogger.Warning($"[OSNotification] Toast failed (using fallback): {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -283,7 +283,7 @@ namespace ADOFAIRenderer.Util
|
||||
|
||||
if (hWnd == IntPtr.Zero)
|
||||
{
|
||||
MelonLogger.Warning("[OSNotification] 메시지 윈도우를 생성할 수 없습니다.");
|
||||
MelonLogger.Warning("[OSNotification] Cannot create message window.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -334,7 +334,7 @@ namespace ADOFAIRenderer.Util
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Warning(
|
||||
$"[OSNotification] 폴더를 열 수 없습니다: {ex.Message}");
|
||||
$"[OSNotification] Cannot open folder: {ex.Message}");
|
||||
}
|
||||
|
||||
break;
|
||||
@@ -357,7 +357,7 @@ namespace ADOFAIRenderer.Util
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Warning($"[OSNotification] Balloon 알림 실패: {ex.Message}");
|
||||
MelonLogger.Warning($"[OSNotification] Balloon notification failed: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -465,7 +465,7 @@ namespace ADOFAIRenderer.Util
|
||||
};
|
||||
startInfo.ArgumentList.Add("-a");
|
||||
startInfo.ArgumentList.Add("ADOFAI Renderer");
|
||||
startInfo.ArgumentList.Add("--action=open_folder=렌더 폴더 열기");
|
||||
startInfo.ArgumentList.Add("--action=open_folder=Open Render Folder");
|
||||
startInfo.ArgumentList.Add(title);
|
||||
startInfo.ArgumentList.Add(message);
|
||||
|
||||
@@ -491,7 +491,7 @@ namespace ADOFAIRenderer.Util
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Warning($"[OSNotification] 리눅스 알림 처리 실패: {ex.Message}");
|
||||
MelonLogger.Warning($"[OSNotification] Linux notification processing failed: {ex.Message}");
|
||||
|
||||
// Fallback: show without action
|
||||
ShowSimple(title, message);
|
||||
@@ -554,7 +554,7 @@ namespace ADOFAIRenderer.Util
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Warning($"[OSNotification] gdbus 알림 실패: {ex.Message}");
|
||||
MelonLogger.Warning($"[OSNotification] gdbus notification failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
-12
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
|
||||
namespace ADOFAIRenderer.Util
|
||||
{
|
||||
@@ -7,49 +7,49 @@ namespace ADOFAIRenderer.Util
|
||||
public static (int Numerator, int Denominator) Double2MaxPrecisionInt(double value,
|
||||
int maxDenominator = 100000000)
|
||||
{
|
||||
// 1. 예외 처리 및 부호 판별
|
||||
// 1. Edge case handling and sign detection
|
||||
if (value == 0.0) return (0, 1);
|
||||
|
||||
var isNegative = value < 0;
|
||||
var target = Math.Abs(value);
|
||||
// 2. 스턴-브로코 / 연속분수 경계값 초기화
|
||||
// 아래 두 행의 분수 (0/1, 1/0) 사이에서 시작하여 타겟에 가까워집니다.
|
||||
// 2. Stern-Brocot / continued fraction boundary initialization
|
||||
// Starts between (0/1, 1/0) and converges towards the target value.
|
||||
long m00 = 0, m01 = 1;
|
||||
long m10 = 1, m11 = 0;
|
||||
|
||||
var x = target;
|
||||
|
||||
// int 범위 오버플로우를 방지하면서 반복 처리
|
||||
// Iterate while preventing integer overflow
|
||||
while (true)
|
||||
{
|
||||
var ai = (long)Math.Floor(x);
|
||||
|
||||
// 다음 단계의 분자, 분모 계산 (오버플로우 방지를 위해 long으로 임시 계산)
|
||||
// Calculate next numerator and denominator (temporarily computed as long to prevent overflow)
|
||||
var nextNumerator = ai * m10 + m00;
|
||||
var nextDenominator = ai * m11 + m01;
|
||||
|
||||
// int 범위를 벗어나거나, 설정한 최대 분모를 초과하면 이전 단계의 최적값을 반환
|
||||
// If exceeding integer range or specified maximum denominator, return previous optimal value
|
||||
if (nextNumerator > int.MaxValue || nextDenominator > maxDenominator)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
// 행렬 업데이트
|
||||
// Update matrix
|
||||
m00 = m10; m01 = m11;
|
||||
m10 = nextNumerator; m11 = nextDenominator;
|
||||
|
||||
// 오차가 거의 없으면 (정밀도 도달) 종료
|
||||
// Terminate if error is negligible (precision limit reached)
|
||||
if (Math.Abs(x - ai) < 1e-15) break;
|
||||
|
||||
// 다음 연속분수 항 준비
|
||||
// Prepare next continued fraction term
|
||||
x = 1.0 / (x - ai);
|
||||
}
|
||||
|
||||
// 3. 최종 결정된 int 기반의 분자 분모
|
||||
// 3. Final determined integer numerator and denominator
|
||||
var finalNumerator = (int)m10;
|
||||
var finalDenominator = (int)m11;
|
||||
|
||||
// 부호 복원
|
||||
// Restore sign
|
||||
if (isNegative) finalNumerator = -finalNumerator;
|
||||
|
||||
return (finalNumerator, finalDenominator);
|
||||
|
||||
@@ -63,6 +63,7 @@ namespace ADOFAIRenderer.Util
|
||||
}
|
||||
}
|
||||
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public static string[]? GetHKLMSubKeyNames(string parentKeyPath)
|
||||
{
|
||||
if (RegistryKeyType == null || OpenBaseKeyMethod == null || OpenSubKeyMethod == null || GetSubKeyNamesMethod == null)
|
||||
@@ -94,6 +95,7 @@ namespace ADOFAIRenderer.Util
|
||||
}
|
||||
}
|
||||
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public static string? GetHKLMValue(string keyPath, string valueName)
|
||||
{
|
||||
if (RegistryKeyType == null || OpenBaseKeyMethod == null || OpenSubKeyMethod == null || GetValueMethod == null)
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using MelonLoader;
|
||||
|
||||
namespace ADOFAIRenderer.Util
|
||||
{
|
||||
internal static class StreamUtil
|
||||
{
|
||||
public static void WriteNativePtrToStream(IntPtr nativePtr, int length, Stream destinationStream)
|
||||
{
|
||||
if (nativePtr == IntPtr.Zero)
|
||||
throw new ArgumentNullException(nameof(nativePtr));
|
||||
if (length <= 0) return;
|
||||
|
||||
unsafe
|
||||
{
|
||||
try
|
||||
{
|
||||
var span = new ReadOnlySpan<byte>((void*)nativePtr, length);
|
||||
destinationStream.Write(span);
|
||||
}
|
||||
catch (IOException err)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Error($"FFmpeg 파이프가 예상치 못하게 닫혔습니다: {err.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user