89 Commits
Author SHA256 Message Date
copecone eb648bbe09 Upgrade Version 2026-10-08 18:33:11 +09:00
copecone f1d56f5f9d Add Planet Color Config 2026-10-08 18:32:44 +09:00
copecone ed61cef4bb Improve Video Encoding Options in RenderConfigWindow 2026-10-08 15:44:40 +09:00
copecone 2cfd14e348 Remove scrDecorationPatch 2026-10-08 15:32:22 +09:00
copecone daed6adcd7 Upgrade Version 2026-10-07 15:50:28 +09:00
copecone 86c8a87a0f Optimize Frame Readback and Encoding Pipeline 2026-10-07 15:48:09 +09:00
copecone 892f1ca7b9 Cleanup FolderDialogUtil 2026-10-07 15:47:31 +09:00
copecone e7ef081e55 Rename FFmpegVideoBGPlayer to VideoBGPlayer 2026-10-07 15:44:35 +09:00
copecone a523efd4fc Simplify Singleton in RendererUIManager 2026-10-07 11:47:40 +09:00
copecone b43c8be5dc Fix Render Height Binding in RenderConfigWindow 2026-10-07 11:47:40 +09:00
copecone 671bdda9a1 Refactor 2026-10-07 11:47:40 +09:00
copecone 8567878f07 Remove Legacy Resource Loading 2026-10-07 11:47:40 +09:00
copecone b900a5444e Finalize O5Kit Theme & Window Generation 2026-10-07 11:47:40 +09:00
copecone 2d70fb5ae8 Add License Exception 2026-10-07 11:47:36 +09:00
copecone 0d9cf44c77 Cleanup 2026-10-06 16:31:53 +09:00
copecone 51626f65c7 Add Version Label to Config Window 2026-10-06 15:30:20 +09:00
copecone 65669d5afd Remove 24kHz Sample Rate 2026-10-06 15:06:21 +09:00
copecone 5929b5ddbe Fix Folder Selection Browser 2026-10-06 15:06:05 +09:00
copecone a7c64e6c27 Add DelayAfterEnd Default Value 2026-10-06 14:01:06 +09:00
copecone 36c569057c Remove 96kHz for Opus 2026-10-06 14:00:54 +09:00
copecone b05e18c8bb Refactor RegistryUtil 2026-10-06 14:00:00 +09:00
copecone 888663aa86 Add Snap to Render Resolution Sliders 2026-10-06 13:53:11 +09:00
copecone 1a8fcaec7d Adjust Config Default Values 2026-10-06 13:52:45 +09:00
copecone 394fe00b25 Refactor Audio Codec & Sample Rate Detection 2026-10-06 13:51:52 +09:00
copecone 48a56cd3f2 Fix NullReferenceException from PlanetRendererPatch.OverrideColor 2026-10-06 01:09:05 +09:00
copecone 63f38883c8 Merge remote-tracking branch 'origin/main' 2026-10-05 23:46:00 +09:00
copecone 24ec6aa1d0 Upgrade Version 2026-10-05 23:45:51 +09:00
copecone 44caf92ada Merge pull request 'Apply O5Kit UI' (#4) from feature/o5kit_ui into main
Reviewed-on: #4
2026-10-05 23:45:15 +09:00
copecone b3e50cd50e Finalize First O5Kit Config UI Prototype 2026-10-05 23:37:11 +09:00
copecone 1d4b1cdd46 Remove RenderConfigUIManager 2026-10-05 23:35:31 +09:00
copecone 98cf00578d Merge pull request 'Fix Render Config UI Prototype Layout' (#3) from kyulio/ADOFAIRenderer:feature/o5kit_ui into feature/o5kit_ui
Reviewed-on: #3
2026-10-05 17:03:41 +09:00
kyulio b6f1c0e4d2 Fix Render Config UI Prototype Layout 2026-10-05 16:21:26 +09:00
copecone 477b46a8dc Add Config UI Prototype 2026-10-05 15:36:59 +09:00
copecone 6e348ecae3 Fix Font Load 2026-10-05 15:26:38 +09:00
copecone b15903f1e0 Refactor Asset Loading 2026-10-05 14:50:21 +09:00
copecone 6c0347609d Add Auto Localization Initialization 2026-10-05 14:30:01 +09:00
copecone 92a4654f14 Add Auto UI Initialization 2026-10-05 14:28:18 +09:00
copecone ec5b2f0ab6 Add Embedded Resource Config 2026-10-05 14:25:39 +09:00
copecone 85575d3185 Add Bundles 2026-10-05 14:24:18 +09:00
copecone 2277025829 Implement O5Kit 2026-10-05 13:49:02 +09:00
copecone 5e9c0272dd Add Planet Color Configuration 2026-10-02 20:16:30 +09:00
copecone e3cb2263d9 Delete Bundles 2026-09-30 20:29:04 +09:00
copecone 4c68b90f9d Update LICENSE 2026-09-29 14:04:59 +09:00
copecone e9b9bac300 Rename LICENSE.md to LICENSE 2026-09-29 13:06:25 +09:00
copecone 87fbcd70ae Relicense from MIT to GPLv3 2026-09-29 12:57:52 +09:00
copecone ea1a193937 Add README.md 2026-09-28 12:28:45 -04:00
copecone d42c434780 Apply Stage-based Condition Management 2026-09-28 21:15:02 +09:00
copecone 363130e87b Remove Useless Render Frame Skip Check 2026-09-17 10:41:26 +09:00
copecone 8014975c81 Update to Support 3.4.0 2026-09-17 10:33:56 +09:00
copecone f964b685c8 Merge remote-tracking branch 'origin/main' 2026-09-17 10:33:16 +09:00
copecone 573e540ba4 Refactor RenderMod 2026-09-16 20:52:16 +09:00
copecone a237814abc Upgrade Version 2026-09-06 14:30:10 +09:00
copecone 7100609809 Add Language System 2026-09-06 14:29:46 +09:00
copecone 43763b0b35 Add Output Path Config Prototype 2026-09-01 08:09:55 +09:00
copecone 4097e14e7a Fix Intel GPU Regex Pattern 2026-08-31 12:43:58 +09:00
copecone 463d95d16f Add Debug Log When Skipping Encoder 2026-08-31 12:43:38 +09:00
copecone c24f9afbae Translate UI Default Texts to English 2026-08-23 12:36:42 +09:00
copecone 25c38707c3 Translate Codebase Comments and Log Messages to English 2026-08-21 23:35:02 +09:00
copecone 111f5cf792 Add OnLastUpdate Callback to API 2026-08-20 10:50:02 +09:00
copecone 5ffd73052d Rename wonFrame to endFrame to Fit Codebase Semantics 2026-08-20 10:21:14 +09:00
copecone 003319fa26 Fix Render Display Text to Always be Visible 2026-08-20 10:14:26 +09:00
copecone 257592f3c1 Add Expiration Date 2026-08-20 09:06:05 +09:00
copecone f14942a287 Add Version Label 2026-08-20 08:39:17 +09:00
copecone 27be2141f7 Refactor Every namespace Keyword Usage 2026-08-19 15:40:00 +09:00
copecone 4e67cb9b09 Add RenderSession.OnComplete 2026-08-19 15:08:55 +09:00
copecone 6840395041 Upgrade Version 2026-08-19 08:13:48 +09:00
copecone 642265f2e7 Cleanup Documentation 2026-08-19 08:11:05 +09:00
copecone f81acd8152 Merge remote-tracking branch 'origin/main' 2026-08-19 08:01:56 +09:00
copecone 2d88a0d99c Suppress Unused Warning in API Classes 2026-08-19 07:57:14 +09:00
copecone 2c334edec6 Merge remote-tracking branch 'origin/main' 2026-08-19 07:56:04 +09:00
copecone 7e5a92162b Fix ADOFAIRenderer.csproj to Generate Documentation File 2026-08-19 07:55:48 +09:00
copecone 838b0fccfd Merge remote-tracking branch 'origin/main' 2026-08-18 14:16:52 +09:00
copecone 72ba78579a Add RenderResult 2026-08-18 14:16:40 +09:00
copecone 71c7fa0016 Document RenderSession.WithOutputPath 2026-08-18 11:46:52 +09:00
copecone 41a961ce1e Remove Redundant Directory Init 2026-08-18 11:39:51 +09:00
copecone 5ef14680ba Refactor Config UI Codes 2026-08-18 11:31:52 +09:00
copecone 9b432eae8f Rename AdvancedConfig.Extension to FileExtension 2026-08-18 11:22:43 +09:00
copecone c3de49fff5 Add Custom Extension in Advanced Config 2026-08-18 11:02:08 +09:00
copecone bdc8b4abb0 Add Custom Output Path API 2026-08-18 11:02:08 +09:00
copecone 2a5ceb4889 Add RenderSession.WithOutputPath 2026-08-18 11:02:08 +09:00
copecone d8712c971e Refactor FFmpegDownloadUI 2026-08-18 11:02:08 +09:00
copecone a47cb39f17 Upgrade Version 2026-08-18 00:57:29 +09:00
copecone c99f98a633 Document RenderSession.WithEndCondition 2026-08-18 00:23:34 +09:00
copecone 05d43f3bc2 Add WithEndCondition to RenderSession 2026-08-18 00:19:04 +09:00
copecone 437a8b598e Add Level Loading Override in RenderSession.Start 2026-08-18 00:00:29 +09:00
copecone d7b3f48d32 Add RenderContext Reset Mechanism 2026-08-17 21:46:53 +09:00
copecone 1fda8b09a7 Document RenderSession 2026-08-17 21:46:14 +09:00
copecone a98617b7a2 Fix Bundles 2026-08-17 21:40:17 +09:00
copecone 3b06343c19 Update Bundles 2026-08-17 21:29:12 +09:00
80 changed files with 5316 additions and 3736 deletions

No files matched your search

+1
View File
@@ -42,3 +42,4 @@ GEMINI.md
# Developer Description Files
/AsyncInputAutoplayFix.md
/AudioMixerExplanation.md
/O5Theme.md
+85 -29
View File
@@ -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>
@@ -77,36 +82,87 @@
<Reference Include="Newtonsoft.Json">
<HintPath>$(ADOFAI_PATH)\A Dance of Fire and Ice_Data\Managed\Newtonsoft.Json.dll</HintPath>
</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" />
<Reference Include="UniTask">
<HintPath>$(ADOFAI_PATH)\A Dance of Fire and Ice_Data\Managed\UniTask.dll</HintPath>
</Reference>
</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>
+122
View File
@@ -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"}]";
}
}
+108 -7
View File
@@ -1,24 +1,50 @@
using System;
using System;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using ADOFAIRenderer.Core;
using ADOFAIRenderer.Data;
using ADOFAIRenderer.Util;
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;
public enum PatchType { Time, Screen }
private bool _timePatch = true;
private bool _screenPatch = true;
/// <summary>
/// Specifies the types of patches that can be configured for a render session.
/// </summary>
public enum PatchType
{
/// <summary>
/// Time-related patch controlling frame timing and clock synchronization.
/// </summary>
Time,
/// <summary>
/// Sets whether automatic rendering is enabled.
/// Screen-related patch controlling screen resolution.
/// </summary>
/// <param name="enabled"><c>true</c> to enable automatic rendering; otherwise, <c>false</c>. Default is <c>true</c>.</param>
Screen
}
private bool _timePatch = true;
private bool _screenPatch = true;
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>
/// <param name="enabled"><c>true</c> to enable auto mode; otherwise, <c>false</c>. Default is <c>true</c>.</param>
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
public RenderSession WithAuto(bool enabled = true)
{
@@ -26,6 +52,13 @@ public class RenderSession
return this;
}
/// <summary>
/// Sets whether a specific patch type is enabled for the render session.
/// </summary>
/// <param name="patchType">The <see cref="PatchType"/> to configure.</param>
/// <param name="enabled"><c>true</c> to enable the specified patch; otherwise, <c>false</c>. Default is <c>true</c>.</param>
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
/// <exception cref="ArgumentOutOfRangeException">Thrown when an unsupported <see cref="PatchType"/> is provided.</exception>
public RenderSession WithPatch(PatchType patchType, bool enabled = true)
{
switch (patchType)
@@ -38,19 +71,87 @@ public class RenderSession
return this;
}
/// <summary>
/// Sets the custom condition function that determines when the render session should end.
/// </summary>
/// <param name="endCondition">A delegate returning <c>true</c> when the session end condition is met.</param>
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
public RenderSession WithEndCondition(Func<bool> endCondition)
{
_endCondition = endCondition;
return this;
}
/// <summary>
/// Sets the custom output path for the rendered file.
/// </summary>
/// <param name="outputPath">The file path where the output will be saved, or <c>null</c> to use the default capture directory.</param>
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
public RenderSession WithOutputPath(string? outputPath)
{
_outputPath = outputPath;
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.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.SetupCaptureDirectory();
RenderManager.Instance.stage = RenderStage.Load;
if (_outputPath is null) RenderManager.Instance.SetupCaptureDirectory();
else FileUtil.CreateDirectory(Path.GetDirectoryName(_outputPath) ?? throw new InvalidOperationException());
RenderManager.Instance.SetupGameScene();
}
}
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
+79
View File
@@ -0,0 +1,79 @@
using System;
using System.IO;
using MelonLoader;
using MelonLoader.Utils;
using Newtonsoft.Json;
namespace ADOFAIRenderer.Core.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}");
}
}
}
+9 -10
View File
@@ -1,8 +1,8 @@
using ADOFAIRenderer.UI;
using UnityEngine;
namespace ADOFAIRenderer.Core
{
namespace ADOFAIRenderer.Core;
[DefaultExecutionOrder(int.MinValue)]
internal class FirstUpdateHook : MonoBehaviour
{
@@ -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
})
);
}
}
}
+12 -10
View File
@@ -1,14 +1,15 @@
using System.Collections;
using System.Reflection;
using Cysharp.Threading.Tasks;
using HarmonyLib;
using UnityEngine;
namespace ADOFAIRenderer.Core
{
namespace ADOFAIRenderer.Core;
[DefaultExecutionOrder(int.MaxValue)]
internal class PostRenderHook : MonoBehaviour
{
private FieldInfo? _camQuadMeshField;
private MeshRenderer? _camQuadMeshRenderer;
private void Awake()
{
@@ -20,16 +21,17 @@ namespace ADOFAIRenderer.Core
if (RenderManager.Instance)
RenderManager.Instance.OnLastUpdate();
StartCoroutine(PostFrameCoroutine());
PostFrameAsync().Forget();
}
private IEnumerator PostFrameCoroutine()
private async UniTaskVoid PostFrameAsync()
{
yield return new WaitForEndOfFrame();
await UniTask.WaitForEndOfFrame(this, destroyCancellationToken);
var val = (MeshRenderer)_camQuadMeshField!.GetValue(scrCamera.instance);
if (RenderManager.Instance)
RenderManager.Instance.OnPostFrame(val.sharedMaterial.mainTexture as RenderTexture);
}
if (!_camQuadMeshRenderer && scrCamera.instance)
_camQuadMeshRenderer = _camQuadMeshField?.GetValue(scrCamera.instance) as MeshRenderer;
if (RenderManager.Instance && _camQuadMeshRenderer)
RenderManager.Instance.OnPostFrame(_camQuadMeshRenderer.sharedMaterial.mainTexture as RenderTexture);
}
}
+189
View File
@@ -0,0 +1,189 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using MelonLoader;
using MelonLoader.Utils;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace ADOFAIRenderer.Data;
internal class RenderPreset
{
public string Name { get; set; } = string.Empty;
public RenderConfig Config { get; set; } = null!;
}
internal static class PresetManager
{
private static string GetPresetsDirectory()
{
return Path.Combine(MelonEnvironment.GameRootDirectory, "UserData", "ADOFAIRenderer", "Presets");
}
private static string GetSafeFileName(string name)
{
return Path.GetInvalidFileNameChars().Aggregate(name, (current, c) => current.Replace(c, '_'));
}
public static void SavePreset(string name, RenderConfig config)
{
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException("Preset name cannot be empty.");
}
string dir = GetPresetsDirectory();
if (!Directory.Exists(dir))
{
Directory.CreateDirectory(dir);
}
var preset = new RenderPreset
{
Name = name,
Config = config
};
string json = JsonConvert.SerializeObject(preset, Formatting.Indented);
string safeName = GetSafeFileName(name);
string filePath = Path.Combine(dir, safeName + ".json");
File.WriteAllText(filePath, json);
}
public static List<RenderPreset> GetPresets()
{
var presets = new List<RenderPreset>();
var dir = GetPresetsDirectory();
if (!Directory.Exists(dir)) return presets;
try
{
var files = Directory.GetFiles(dir, "*.json");
foreach (var file in files)
{
try
{
var json = File.ReadAllText(file);
var jObject = JsonConvert.DeserializeObject<JObject>(json);
if (jObject == null) continue;
var configToken = jObject["Config"];
if (configToken is JObject configObj)
{
TryChangeKey(configObj, "DelayAfterLevelEnd", "DelayAfterEnd");
// 1. legacy EncodingConfig -> VideoConfig
if (configObj["EncodingConfig"] is JObject oldEncodingConfigObj)
{
configObj["VideoConfig"] ??= oldEncodingConfigObj;
configObj.Remove("EncodingConfig");
}
// 2. legacy top-level Codec -> VideoConfig.Codec
var legacyCodecToken = configObj["Codec"];
if (legacyCodecToken != null && legacyCodecToken.Type != JTokenType.Null)
{
if (configObj["VideoConfig"] == null || configObj["VideoConfig"]!.Type == JTokenType.Null)
{
configObj["VideoConfig"] = new JObject();
}
var videoConfigObj = (JObject)configObj["VideoConfig"]!;
videoConfigObj["Codec"] ??= legacyCodecToken;
configObj.Remove("Codec");
}
// 3. legacy top-level Audio properties -> AudioConfig
string[] legacyAudioProps = { "RenderAudio", "AudioSampleRate", "AudioChannels", "AudioBitrate" };
foreach (var prop in legacyAudioProps)
{
var legacyToken = configObj[prop];
if (legacyToken != null && legacyToken.Type != JTokenType.Null)
{
if (configObj["AudioConfig"] == null || configObj["AudioConfig"]!.Type == JTokenType.Null)
{
configObj["AudioConfig"] = new JObject();
}
var audioConfigObj = (JObject)configObj["AudioConfig"]!;
audioConfigObj[prop] ??= legacyToken;
}
configObj.Remove(prop);
}
if (configObj["AudioConfig"] != null && configObj["AudioConfig"]!.Type == JTokenType.Null)
{
configObj.Remove("AudioConfig");
}
// 4. legacy top-level Advanced properties -> AdvancedConfig
string[] legacyAdvancedProps = { "RandomSeed", "ProcessAudio", "NonTile" };
foreach (var prop in legacyAdvancedProps)
{
var legacyToken = configObj[prop];
if (legacyToken != null && legacyToken.Type != JTokenType.Null)
{
if (configObj["AdvancedConfig"] == null || configObj["AdvancedConfig"]!.Type == JTokenType.Null)
{
configObj["AdvancedConfig"] = new JObject();
}
var advancedConfigObj = (JObject)configObj["AdvancedConfig"]!;
advancedConfigObj[prop] ??= legacyToken;
}
configObj.Remove(prop);
}
if (configObj["AdvancedConfig"] != null && configObj["AdvancedConfig"]!.Type == JTokenType.Null)
{
configObj.Remove("AdvancedConfig");
}
}
var preset = jObject.ToObject<RenderPreset>();
if (preset != null && !string.IsNullOrEmpty(preset.Name))
{
presets.Add(preset);
}
}
catch (Exception ex)
{
MelonLogger.Warning($"Error loading preset file '{Path.GetFileName(file)}': {ex.Message}");
}
}
}
catch (Exception ex)
{
MelonLogger.Error($"Error retrieving preset list: {ex.Message}");
}
return presets;
}
private static void TryChangeKey(JObject configObj, string keyBefore, string targetKey)
{
var legacyToken = configObj[keyBefore];
if (legacyToken != null && legacyToken.Type != JTokenType.Null)
configObj[targetKey] = legacyToken;
configObj.Remove(keyBefore);
}
public static void DeletePreset(string name)
{
var dir = GetPresetsDirectory();
var safeName = GetSafeFileName(name);
var filePath = Path.Combine(dir, safeName + ".json");
if (File.Exists(filePath))
{
File.Delete(filePath);
}
}
}
+142 -167
View File
@@ -4,35 +4,70 @@ using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Buffers;
using System.Diagnostics;
using System.Linq;
using ADOFAIRenderer.API;
using ADOFAIRenderer.Core.Data;
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
{
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;
Notification = true;
EndCondition = EndConditionDefault;
OnComplete = OnCompleteDefault;
OnLastUpdate = OnLastUpdateDefault;
}
}
@@ -40,14 +75,14 @@ namespace ADOFAIRenderer.Core
{
// 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;
@@ -58,20 +93,16 @@ 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;
public int frameCountBase;
public OfflineAudioMixer? AudioMixer { get; private set; }
public AudioSyncCalculator SyncCalculator { get; private set; } = null!;
private AudioSyncCalculator SyncCalculator { get; set; } = null!;
private FrameReader? _frameReader;
public bool renderCheck = true;
public int skipCount;
public long frameCountForVideo;
public long lastVideoFrameIndex = -1;
@@ -86,6 +117,7 @@ namespace ADOFAIRenderer.Core
public int Length;
public byte[]? ManagedBuffer; // Non-null if it's a managed buffer (e.g. black frame)
public ReadbackRequestState? ReadbackState; // Associated state to recycle
public bool IsColorConverted;
}
// Thread and Queue State
@@ -95,7 +127,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;
@@ -103,7 +135,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()
@@ -116,40 +148,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()
@@ -160,7 +162,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;
}
@@ -169,7 +171,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
@@ -178,7 +180,7 @@ namespace ADOFAIRenderer.Core
}
catch
{
// Ignore
// ignored
}
}
_allVideoFFmpegInstances.Clear();
@@ -219,9 +221,8 @@ namespace ADOFAIRenderer.Core
scrController.instance.noFailInfiniteMargin = true;
}
StartRendering();
countdown = 3;
wonFrame = -1;
stage = RenderStage.OnScene;
countdown = 3; endFrame = -1;
if (!_escapeHandlerObj)
{
@@ -234,7 +235,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);
@@ -243,93 +244,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)
@@ -383,19 +338,16 @@ 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");
if (!Directory.Exists(renderDir))
{
Directory.CreateDirectory(renderDir);
}
var renderDir = GlobalSettings.EffectiveOutputDirectory;
FileUtil.CreateDirectory(renderDir);
}
public void SetupGameScene()
@@ -414,9 +366,10 @@ namespace ADOFAIRenderer.Core
private IEnumerator SetupPlay()
{
if (Configuration.AudioConfig.RenderAudio && GetOutputExtension(Configuration.VideoConfig.Codec) != ".mp4")
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--)
@@ -424,8 +377,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;
@@ -440,11 +394,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");
})
@@ -452,7 +406,7 @@ namespace ADOFAIRenderer.Core
yield break;
}
StartCapturing();
Instance.stage = RenderStage.Render;
scrController.instance.paused = scrController.instance.audioPaused = false;
scrController.instance.levelWasSkipped = true;
@@ -493,8 +447,6 @@ namespace ADOFAIRenderer.Core
frameCountForVideo = 0;
lastVideoFrameIndex = -1;
lastRequestedVideoFrameIndex = -1;
renderCheck = true;
skipCount = 0;
endRequested = false;
endSuccessful = false;
FrameAvailableEvent.Reset();
@@ -502,24 +454,25 @@ namespace ADOFAIRenderer.Core
var renderFPS = Configuration.RenderFPS;
var videoFPS = Configuration.VideoFPS;
var selectedCodec = Configuration.VideoConfig.Codec;
var ext = GetOutputExtension(selectedCodec);
var startTime = DateTime.Now;
var fileName = startTime.ToString("yyyy-MM-dd HH-mm-ss") + ext;
_currentOutPath = Path.Combine(MelonEnvironment.GameRootDirectory, "Render", fileName);
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(GlobalSettings.EffectiveOutputDirectory, fileName);
}
FFmpegNativeEncoder? videoFFmpeg = null;
try
{
var effectiveVideoFps = Math.Min(videoFPS, renderFPS);
var codecId = GetAudioCodecId(ext);
var codecId = CodecUtil.AudioCodecMatch[Configuration.VideoConfig.Codec];
var settings = new FFmpegNativeEncoder.EncoderSettings
{
Width = Configuration.RenderWidth,
Height = Configuration.RenderHeight,
FPS = effectiveVideoFps,
Codec = selectedCodec,
Codec = Configuration.VideoConfig.Codec,
Mode = Configuration.VideoConfig.RateControlMode,
QualityValue = Configuration.VideoConfig.QP,
TargetBitrate = Configuration.VideoConfig.Bitrate,
@@ -538,14 +491,12 @@ namespace ADOFAIRenderer.Core
videoFFmpeg = new FFmpegNativeEncoder(settings);
SetupCaptureDirectory();
_activeVideoFFmpeg = videoFFmpeg;
lock (_allVideoFFmpegInstances)
{
_allVideoFFmpegInstances.Add(videoFFmpeg);
}
videoFFmpeg.Start(_currentOutPath);
videoFFmpeg.Start(CurrentOutPath);
}
catch (Exception)
{
@@ -575,7 +526,7 @@ namespace ADOFAIRenderer.Core
{
var firstKey = ImageQueue.Keys[0];
if (firstKey > passCount && !IsCapturing)
if (firstKey > passCount && Instance.stage < RenderStage.Render)
{
passCount = firstKey;
}
@@ -596,7 +547,7 @@ namespace ADOFAIRenderer.Core
{
if (frame.ManagedBuffer.Length > 0)
{
videoFFmpeg.SendDataFromBuffer(frame.ManagedBuffer, frame.Length);
videoFFmpeg.SendDataFromBuffer(frame.ManagedBuffer, frame.Length, frame.IsColorConverted);
Interlocked.Increment(ref framesSent);
ArrayPool<byte>.Shared.Return(frame.ManagedBuffer);
@@ -604,7 +555,7 @@ namespace ADOFAIRenderer.Core
}
else if (frame.NativePointer != IntPtr.Zero && frame.Length > 0)
{
videoFFmpeg.SendDataFromNativeBuffer(frame.NativePointer, frame.Length);
videoFFmpeg.SendDataFromNativeBuffer(frame.NativePointer, frame.Length, frame.IsColorConverted);
Interlocked.Increment(ref framesSent);
if (frame.ReadbackState != null)
@@ -659,7 +610,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)
{
@@ -703,22 +654,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)
@@ -737,14 +688,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;
@@ -754,37 +705,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();
}
@@ -825,23 +779,44 @@ namespace ADOFAIRenderer.Core
AudioMixer?.Reset(); AudioMixer = null;
SyncCalculator.Reset();
RenderContext.Reset();
ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnEditor");
Cursor.visible = true;
}
private string GetOutputExtension(Codec codec)
private RenderResult BuildResult(bool success, string? errorMessage = null)
{
return codec switch
{
Codec.AV1 or Codec.VP9 => ".webm",
Codec.VVC => ".mkv",
_ => ".mp4"
};
_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 AVCodecID GetAudioCodecId(string ext)
private static void TriggerOnComplete(RenderResult result)
{
return ext == ".mp4" ? AVCodecID.AV_CODEC_ID_AAC : AVCodecID.AV_CODEC_ID_OPUS;
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
View File
@@ -0,0 +1,29 @@
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using FFmpeg.AutoGen;
namespace ADOFAIRenderer.Data;
[SuppressMessage("ReSharper", "InconsistentNaming")]
public enum Codec
{
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 }
};
}
-78
View File
@@ -1,78 +0,0 @@
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>
/// Validates the given double value against this limit.
/// </summary>
/// <param name="value">The parsed double value to validate.</param>
/// <param name="errorMessage">
/// When validation fails, set to a human-readable error message.
/// When validation succeeds, set to null.
/// </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
{
public override bool Validate(double value, out string errorMessage)
{
if (value > 0)
{
errorMessage = null!;
return true;
}
errorMessage = "값은 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
{
public double? Min { get; }
public double? Max { get; }
/// <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>
public RangeDoubleLimit(double? min, double? max)
{
Min = min;
Max = max;
}
public override bool Validate(double value, out string errorMessage)
{
if (Min.HasValue && value < Min.Value)
{
errorMessage = Max.HasValue
? $"값은 {Min.Value}에서 {Max.Value} 사이의 숫자여야 합니다."
: $"값은 {Min.Value} 이상의 숫자여야 합니다.";
return false;
}
if (Max.HasValue && value > Max.Value)
{
errorMessage = Min.HasValue
? $"값은 {Min.Value}에서 {Max.Value} 사이의 숫자여야 합니다."
: $"값은 {Max.Value} 이하의 숫자여야 합니다.";
return false;
}
errorMessage = null!;
return true;
}
}
}
-77
View File
@@ -1,77 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using MelonLoader;
using UnityEngine.UIElements;
namespace ADOFAIRenderer.Data.Input
{
internal class DoubleInputValidator
{
private struct FieldEntry
{
public List<DoubleLimit> Limits;
}
private readonly VisualElement _rootVisualElement;
private readonly Dictionary<string, FieldEntry> _fields = new();
public DoubleInputValidator(VisualElement rootVisualElement)
{
_rootVisualElement = rootVisualElement;
}
/// <summary>
/// Registers one or more <see cref="DoubleLimit"/>s for the field with the given name.
/// Multiple limits can be added to the same field — they are all checked in order.
/// </summary>
/// <param name="fieldName">The name of the DoubleField in the UXML.</param>
/// <param name="limits">The limits to apply.</param>
public DoubleInputValidator AddLimits(string fieldName, params DoubleLimit[] limits)
{
if (!_fields.TryGetValue(fieldName, out var entry))
{
entry = new FieldEntry
{
Limits = new List<DoubleLimit>()
};
}
entry.Limits.AddRange(limits);
_fields[fieldName] = entry;
return this;
}
/// <summary>
/// Queries all registered fields from the root VisualElement and
/// registers value-changed callbacks that rollback invalid values.
/// </summary>
public void SetupValidation()
{
foreach (var (fieldName, entry) in _fields)
{
var field = _rootVisualElement.Q<DoubleField>(fieldName);
if (field == null)
{
Melon<RenderMod>.Logger.Warning(
$"DoubleField '{fieldName}'을(를) 찾을 수 없습니다. 유효성 검사를 건너뜁니다.");
continue;
}
var limits = entry.Limits;
field.RegisterValueChangedCallback(evt =>
{
if (string.IsNullOrEmpty(field.text))
{
return;
}
if (limits.Any(limit => !limit.Validate(evt.newValue, out _)))
{
field.SetValueWithoutNotify(evt.previousValue);
}
});
}
}
}
}
-86
View File
@@ -1,86 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using MelonLoader;
using UnityEngine.UIElements;
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>
internal class EnumDropdownBinder<T> where T : struct, Enum
{
private readonly DropdownField? _dropdown;
private readonly List<T> _values;
private Action<T>? _onValueChanged;
/// <summary>
/// The currently selected enum value.
/// </summary>
public T Value => _dropdown is { index: >= 0 } && _dropdown.index < _values.Count
? _values[_dropdown.index]
: default;
/// <summary>
/// Queries a <see cref="DropdownField"/> by name from the root VisualElement,
/// populates it with enum names, and selects the default value.
/// </summary>
/// <param name="rootVisualElement">The root VisualElement loaded from the UXML bundle.</param>
/// <param name="fieldName">The name of the DropdownField in the UXML.</param>
/// <param name="defaultValue">The initial selected value. Defaults to the first enum member.</param>
public EnumDropdownBinder(VisualElement rootVisualElement, string fieldName, T? defaultValue = null)
{
_values = Enum.GetValues(typeof(T)).Cast<T>().ToList();
_dropdown = rootVisualElement.Q<DropdownField>(fieldName);
if (_dropdown == null)
{
Melon<RenderMod>.Logger.Error(
$"DropdownField '{fieldName}'을(를) 찾을 수 없습니다.");
return;
}
_dropdown.choices = _values.Select(v => v.ToString()).ToList();
// Register the callback BEFORE setting index so the initial selection fires it too
_dropdown.RegisterValueChangedCallback(_ =>
{
_onValueChanged?.Invoke(Value);
});
var initial = defaultValue ?? _values[0];
_dropdown.index = _values.IndexOf(initial);
}
/// <summary>
/// Registers a callback that fires when the selection changes, providing the new enum value.
/// </summary>
public void OnValueChanged(Action<T> callback)
{
_onValueChanged = callback;
// Fire immediately with current value so the caller gets the initial state
if (_dropdown != null)
{
callback(Value);
}
}
/// <summary>
/// Sets the selected enum value and updates the dropdown index.
/// </summary>
/// <param name="value">The enum value to set.</param>
public void SetValue(T value)
{
if (_dropdown == null) return;
int idx = _values.IndexOf(value);
if (idx >= 0)
{
_dropdown.index = idx;
}
}
}
}
-77
View File
@@ -1,77 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using MelonLoader;
using UnityEngine.UIElements;
namespace ADOFAIRenderer.Data.Input
{
internal class IntegerInputValidator
{
private struct FieldEntry
{
public List<IntegerLimit> Limits;
}
private readonly VisualElement _rootVisualElement;
private readonly Dictionary<string, FieldEntry> _fields = new();
public IntegerInputValidator(VisualElement rootVisualElement)
{
_rootVisualElement = rootVisualElement;
}
/// <summary>
/// Registers one or more <see cref="IntegerLimit"/>s for the field with the given name.
/// Multiple limits can be added to the same field — they are all checked in order.
/// </summary>
/// <param name="fieldName">The name of the IntegerField in the UXML.</param>
/// <param name="limits">The limits to apply.</param>
public IntegerInputValidator AddLimits(string fieldName, params IntegerLimit[] limits)
{
if (!_fields.TryGetValue(fieldName, out var entry))
{
entry = new FieldEntry
{
Limits = new List<IntegerLimit>()
};
}
entry.Limits.AddRange(limits);
_fields[fieldName] = entry;
return this;
}
/// <summary>
/// Queries all registered fields from the root VisualElement and
/// registers value-changed callbacks that rollback invalid values.
/// </summary>
public void SetupValidation()
{
foreach (var (fieldName, entry) in _fields)
{
var field = _rootVisualElement.Q<IntegerField>(fieldName);
if (field == null)
{
Melon<RenderMod>.Logger.Warning(
$"IntegerField '{fieldName}'을(를) 찾을 수 없습니다. 유효성 검사를 건너뜁니다.");
continue;
}
var limits = entry.Limits;
field.RegisterValueChangedCallback(evt =>
{
if (string.IsNullOrEmpty(field.text))
{
return;
}
if (limits.Any(limit => !limit.Validate(evt.newValue, out _)))
{
field.SetValueWithoutNotify(evt.previousValue);
}
});
}
}
}
}
-78
View File
@@ -1,78 +0,0 @@
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>
/// Validates the given integer value against this limit.
/// </summary>
/// <param name="value">The parsed integer value to validate.</param>
/// <param name="errorMessage">
/// When validation fails, set to a human-readable error message.
/// When validation succeeds, set to null.
/// </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
{
public override bool Validate(int value, out string errorMessage)
{
if (value > 0)
{
errorMessage = null!;
return true;
}
errorMessage = "값은 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
{
public int? Min { get; }
public int? Max { get; }
/// <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>
public RangeIntegerLimit(int? min, int? max)
{
Min = min;
Max = max;
}
public override bool Validate(int value, out string errorMessage)
{
if (Min.HasValue && value < Min.Value)
{
errorMessage = Max.HasValue
? $"값은 {Min.Value}에서 {Max.Value} 사이의 정수여야 합니다."
: $"값은 {Min.Value} 이상의 정수여야 합니다.";
return false;
}
if (Max.HasValue && value > Max.Value)
{
errorMessage = Min.HasValue
? $"값은 {Min.Value}에서 {Max.Value} 사이의 정수여야 합니다."
: $"값은 {Max.Value} 이하의 정수여야 합니다.";
return false;
}
errorMessage = null!;
return true;
}
}
}
-190
View File
@@ -1,190 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using MelonLoader;
using MelonLoader.Utils;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace ADOFAIRenderer.Data
{
internal class RenderPreset
{
public string Name { get; set; } = string.Empty;
public RenderConfig Config { get; set; } = null!;
}
internal static class PresetManager
{
private static string GetPresetsDirectory()
{
return Path.Combine(MelonEnvironment.GameRootDirectory, "UserData", "ADOFAIRenderer", "Presets");
}
private static string GetSafeFileName(string name)
{
return Path.GetInvalidFileNameChars().Aggregate(name, (current, c) => current.Replace(c, '_'));
}
public static void SavePreset(string name, RenderConfig config)
{
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException("프리셋 이름은 비어 있을 수 없습니다.");
}
string dir = GetPresetsDirectory();
if (!Directory.Exists(dir))
{
Directory.CreateDirectory(dir);
}
var preset = new RenderPreset
{
Name = name,
Config = config
};
string json = JsonConvert.SerializeObject(preset, Formatting.Indented);
string safeName = GetSafeFileName(name);
string filePath = Path.Combine(dir, safeName + ".json");
File.WriteAllText(filePath, json);
}
public static List<RenderPreset> GetPresets()
{
var presets = new List<RenderPreset>();
var dir = GetPresetsDirectory();
if (!Directory.Exists(dir)) return presets;
try
{
var files = Directory.GetFiles(dir, "*.json");
foreach (var file in files)
{
try
{
var json = File.ReadAllText(file);
var jObject = JsonConvert.DeserializeObject<JObject>(json);
if (jObject == null) continue;
var configToken = jObject["Config"];
if (configToken is JObject configObj)
{
TryChangeKey(configObj, "DelayAfterLevelEnd", "DelayAfterEnd");
// 1. legacy EncodingConfig -> VideoConfig
if (configObj["EncodingConfig"] is JObject oldEncodingConfigObj)
{
configObj["VideoConfig"] ??= oldEncodingConfigObj;
configObj.Remove("EncodingConfig");
}
// 2. legacy top-level Codec -> VideoConfig.Codec
var legacyCodecToken = configObj["Codec"];
if (legacyCodecToken != null && legacyCodecToken.Type != JTokenType.Null)
{
if (configObj["VideoConfig"] == null || configObj["VideoConfig"]!.Type == JTokenType.Null)
{
configObj["VideoConfig"] = new JObject();
}
var videoConfigObj = (JObject)configObj["VideoConfig"]!;
videoConfigObj["Codec"] ??= legacyCodecToken;
configObj.Remove("Codec");
}
// 3. legacy top-level Audio properties -> AudioConfig
string[] legacyAudioProps = { "RenderAudio", "AudioSampleRate", "AudioChannels", "AudioBitrate" };
foreach (var prop in legacyAudioProps)
{
var legacyToken = configObj[prop];
if (legacyToken != null && legacyToken.Type != JTokenType.Null)
{
if (configObj["AudioConfig"] == null || configObj["AudioConfig"]!.Type == JTokenType.Null)
{
configObj["AudioConfig"] = new JObject();
}
var audioConfigObj = (JObject)configObj["AudioConfig"]!;
audioConfigObj[prop] ??= legacyToken;
}
configObj.Remove(prop);
}
if (configObj["AudioConfig"] != null && configObj["AudioConfig"]!.Type == JTokenType.Null)
{
configObj.Remove("AudioConfig");
}
// 4. legacy top-level Advanced properties -> AdvancedConfig
string[] legacyAdvancedProps = { "RandomSeed", "ProcessAudio", "NonTile" };
foreach (var prop in legacyAdvancedProps)
{
var legacyToken = configObj[prop];
if (legacyToken != null && legacyToken.Type != JTokenType.Null)
{
if (configObj["AdvancedConfig"] == null || configObj["AdvancedConfig"]!.Type == JTokenType.Null)
{
configObj["AdvancedConfig"] = new JObject();
}
var advancedConfigObj = (JObject)configObj["AdvancedConfig"]!;
advancedConfigObj[prop] ??= legacyToken;
}
configObj.Remove(prop);
}
if (configObj["AdvancedConfig"] != null && configObj["AdvancedConfig"]!.Type == JTokenType.Null)
{
configObj.Remove("AdvancedConfig");
}
}
var preset = jObject.ToObject<RenderPreset>();
if (preset != null && !string.IsNullOrEmpty(preset.Name))
{
presets.Add(preset);
}
}
catch (Exception ex)
{
MelonLogger.Warning($"프리셋 파일 '{Path.GetFileName(file)}' 로드 중 오류 발생: {ex.Message}");
}
}
}
catch (Exception ex)
{
MelonLogger.Error($"프리셋 목록을 불러오는 중 오류 발생: {ex.Message}");
}
return presets;
}
private static void TryChangeKey(JObject configObj, string keyBefore, string targetKey)
{
var legacyToken = configObj[keyBefore];
if (legacyToken != null && legacyToken.Type != JTokenType.Null)
configObj[targetKey] = legacyToken;
configObj.Remove(keyBefore);
}
public static void DeletePreset(string name)
{
string dir = GetPresetsDirectory();
string safeName = GetSafeFileName(name);
string filePath = Path.Combine(dir, safeName + ".json");
if (File.Exists(filePath))
{
File.Delete(filePath);
}
}
}
}
+11
View File
@@ -0,0 +1,11 @@
using System.Diagnostics.CodeAnalysis;
namespace ADOFAIRenderer.Data;
[SuppressMessage("ReSharper", "InconsistentNaming")]
public enum RateControlMode
{
CBR,
VBR,
CQP
}
+16 -9
View File
@@ -1,7 +1,9 @@
using ADOFAIRenderer.Util.FFmpeg;
using System.Diagnostics.CodeAnalysis;
using UnityEngine;
namespace ADOFAIRenderer.Data
{
namespace ADOFAIRenderer.Data;
[SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")]
public class RenderConfig
{
public double RenderFPS { get; set; }
@@ -14,15 +16,21 @@ namespace ADOFAIRenderer.Data
public AudioEncodingConfig AudioConfig { get; set; } = new();
public AdvancedConfig AdvancedConfig { get; set; } = new();
public double DelayAfterEnd { get; set; }
public bool ShowJudgementText { get; set; }
public double DelayAfterEnd { get; set; } = 15;
public bool ShowJudgementText { get; set; } = false;
}
[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 Color? firePlanetColor = null;
public Color? icePlanetColor = null;
public Color? windPlanetColor = null;
}
public class VideoEncodingConfig
@@ -40,7 +48,7 @@ namespace ADOFAIRenderer.Data
public class AudioEncodingConfig
{
private int _audioSampleRate = 44100;
private int _audioSampleRate = 48000;
private int _audioChannels = 2;
private int _audioBitrate = 256000;
@@ -48,8 +56,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
@@ -64,4 +72,3 @@ namespace ADOFAIRenderer.Data
set => _audioBitrate = value <= 0 ? 256000 : value;
}
}
}
+670 -17
View File
@@ -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>.
+42
View File
@@ -0,0 +1,42 @@
# ADOFAIRenderer API & Data Linking Exception (Dynamic Linking Only)
> **Additional Permission under GNU General Public License version 3 section 7**
---
## 1. Legal Terms (Official)
Linking this Program statically or dynamically with other modules is making a combined work based on this Program. Thus, the terms and conditions of the GNU General Public License cover the whole combination.
As a special exception, the copyright holders of this Program give you permission to dynamically link this Program with independent modules that interact with or reference this Program solely through the public application programming interfaces (APIs), types, and data structures declared within the following namespaces:
- `ADOFAIRenderer.API`
- `ADOFAIRenderer.Data`
and to copy, convey, and distribute the resulting combined work under terms of your choice, without requiring such combined work or independent modules to be covered under the GNU General Public License version 3, provided that you also meet, for each linked independent module, the terms and conditions of the license of that module.
This exception applies exclusively to dynamic linking. Static linking, including but not limited to combining, embedding, compiling this Program into another binary, or merging assemblies (such as via ILMerge or Costura), is explicitly excluded from this exception and remains fully subject to the terms and copyleft requirements of the GNU General Public License version 3.
An **"independent module"** is a separate and distinct software module that is not derived from or based on this Program, other than referencing or communicating with the interfaces and types declared within the designated namespaces above.
This exception does not grant any permission to modify the source code of this Program itself outside the terms of the GNU General Public License version 3.
In accordance with section 7 of the GNU GPL version 3, if you modify this Program, you may extend this exception to your version of the Program, but you are not obligated to do so. If you do not wish to provide this exception in your derivative work, you may remove this exception statement from your version.
---
## 2. 조항 해설 (Korean Guide)
본 문서는 GNU General Public License 버전 3 (GPLv3) 제7조(추가적인 허가, Additional Permissions)에 따라 제공되는 공식 예외 조항입니다.
### 핵심 요약
- **동적 링킹(Dynamic Linking)만 허용**:
- 본 예외 조항은 **동적 링킹(런타임 참조 및 어셈블리 동적 로드)**에만 배타적으로 적용됩니다.
- ADOFAIRenderer 또는 그 일부 코드를 대상 모드/프로그램에 직접 병합하거나 포함(ILMerge, Costura, 바이너리 직접 내장/정적 컴파일 등)하는 **정적 링킹(Static Linking)은 본 예외 대상에서 제외**되며, 순수 GPLv3의 카피레프트(전염성) 의무가 그대로 적용됩니다.
- **외부 모드의 라이선스 자유**:
- 제3자 모드 또는 외부 프로그램이 동적 링킹을 통해 `ADOFAIRenderer.API` 및 `ADOFAIRenderer.Data` 네임스페이스의 공개 API, 클래스, 데이터 구조만을 참조·호출하여 연동하는 경우, 해당 모드는 **GPLv3의 카피레프트 의무를 적용받지 않습니다.**
- 외부 모드 개발자는 자신의 모드를 MIT, Apache-2.0, 독점/상용 라이선스 등 원하는 라이선스로 자유롭게 배포할 수 있습니다.
- **ADOFAIRenderer 코어 보호**:
- ADOFAIRenderer 자체의 소스 코드(렌더링 파이프라인, 코어 패치, 내부 유틸리티 등)를 수정하거나 재배포하는 경우에는 본 예외 조항이 적용되지 않으며, 순수 GPLv3 라이선스 조건이 그대로 적용됩니다.
+40
View File
@@ -0,0 +1,40 @@
{
"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.planet_colors": "Planet Colors (Optional)",
"ui.render_config.fire_planet": "Fire Planet",
"ui.render_config.ice_planet": "Ice Planet",
"ui.render_config.wind_planet": "Wind Planet",
"ui.render_config.expired": "(Expired)"
}
+40
View File
@@ -0,0 +1,40 @@
{
"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.planet_colors": "행성 색상 설정 (선택 사항)",
"ui.render_config.fire_planet": "불 행성",
"ui.render_config.ice_planet": "얼음 행성",
"ui.render_config.wind_planet": "바람 행성",
"ui.render_config.expired": "(만료됨)"
}
+246
View File
@@ -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;
}
}
+3 -4
View File
@@ -1,8 +1,8 @@
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
@@ -13,9 +13,8 @@ namespace ADOFAIRenderer.Patch.Engine
{
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
}
}
}
+2 -5
View File
@@ -4,8 +4,8 @@ using HarmonyLib;
using UnityEngine;
using UnityEngine.Audio;
namespace ADOFAIRenderer.Patch.Engine
{
namespace ADOFAIRenderer.Patch.Engine;
[HarmonyPatch(typeof(AudioManager), "_Play")]
public static class AudioManagerPlayPatch
{
@@ -79,8 +79,6 @@ namespace ADOFAIRenderer.Patch.Engine
}
}
[HarmonyPatch(typeof(AudioSource), "SetScheduledEndTime", typeof(double))]
public static class AudioSourceSetScheduledEndTimePatch
{
@@ -112,4 +110,3 @@ namespace ADOFAIRenderer.Patch.Engine
}
}
}
}
+3 -6
View File
@@ -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
{
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 -4
View File
@@ -2,8 +2,8 @@ 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
@@ -40,7 +40,6 @@ namespace ADOFAIRenderer.Patch.Game
for (var i = 0; i < lampSequencesArray.Length; i++)
{
var item = lampSequencesArray.GetValue(i);
if (item == null) continue;
try
{
var killMethod = item.GetType().GetMethod("Kill", new[] { typeof(bool) });
@@ -71,4 +70,3 @@ namespace ADOFAIRenderer.Patch.Game
chainSequenceField.SetValue(null);
}
}
}
+26
View File
@@ -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);
}
}
+17 -2
View File
@@ -1,8 +1,8 @@
using ADOFAIRenderer.Core;
using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game
{
namespace ADOFAIRenderer.Patch.Game;
[HarmonyPatch(typeof(Persistence))]
public static class PersistencePatch
{
@@ -18,5 +18,20 @@ namespace ADOFAIRenderer.Patch.Game
__result = SkipIntroBehavior.Off;
}
}
[HarmonyPostfix]
[HarmonyPatch("GetPlayerColor")]
// ReSharper disable once InconsistentNaming
public static void OverridePlayerColor(bool red, ref PlanetColor __result)
{
if (!RenderManager.Instance || RenderManager.Instance.stage == RenderStage.None) return;
var advConf = RenderManager.Instance.Configuration.AdvancedConfig;
__result = red switch
{
true when advConf.firePlanetColor.HasValue => new PlanetColor(advConf.firePlanetColor.Value),
false when advConf.icePlanetColor.HasValue => new PlanetColor(advConf.icePlanetColor.Value),
_ => __result
};
}
}
+35
View File
@@ -0,0 +1,35 @@
using ADOFAIRenderer.Core;
using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game;
[HarmonyPatch(typeof(PlanetarySystem))]
public static class PlanetarySystemPatch
{
[HarmonyPostfix]
[HarmonyPatch("Init")]
// ReSharper disable once InconsistentNaming
public static void OnInit(PlanetarySystem __instance)
{
ApplyWindPlanetColor(__instance);
}
[HarmonyPostfix]
[HarmonyPatch("ApplyMultiplanetColors")]
// ReSharper disable once InconsistentNaming
public static void OnApplyMultiplanetColors(PlanetarySystem __instance)
{
ApplyWindPlanetColor(__instance);
}
private static void ApplyWindPlanetColor(PlanetarySystem planetarySystem)
{
if (RenderManager.Instance == null || RenderManager.Instance.stage == RenderStage.None) return;
var advConf = RenderManager.Instance.Configuration.AdvancedConfig;
if (advConf.windPlanetColor.HasValue && planetarySystem.planetGreen)
{
planetarySystem.planetGreen.planetRenderer.SetColor(new PlanetColor(advConf.windPlanetColor.Value));
}
}
}
+3 -4
View File
@@ -4,8 +4,8 @@ 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
@@ -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;
}
@@ -48,4 +48,3 @@ namespace ADOFAIRenderer.Patch.Game
iconImage.rectTransform.sizeDelta = new Vector2(24, 24);
}
}
}
+8 -12
View File
@@ -4,8 +4,8 @@ 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
@@ -14,7 +14,7 @@ namespace ADOFAIRenderer.Patch.Game
[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;
@@ -77,11 +77,9 @@ namespace ADOFAIRenderer.Patch.Game
if (!RenderManager.IsActive)
{
// Remove FFmpeg player if it was attached previously
var oldPlayer = __instance.videoBG.gameObject.GetComponent<FFmpegVideoBGPlayer>();
if (oldPlayer != null)
{
Object.Destroy(oldPlayer);
}
var oldPlayer = __instance.videoBG.gameObject.GetComponent<VideoBGPlayer>();
if (oldPlayer) Object.Destroy(oldPlayer);
// Re-enable native player component
__instance.videoBG.enabled = true;
return true; // Execute original
@@ -125,10 +123,9 @@ namespace ADOFAIRenderer.Patch.Game
__instance.videoBG.enabled = false;
// Add or retrieve our custom FFmpeg video background player
var player = __instance.videoBG.gameObject.GetComponent<FFmpegVideoBGPlayer>();
if (player == null)
if (!__instance.videoBG.gameObject.TryGetComponent<VideoBGPlayer>(out var player))
{
player = __instance.videoBG.gameObject.AddComponent<FFmpegVideoBGPlayer>();
player = __instance.videoBG.gameObject.AddComponent<VideoBGPlayer>();
}
var isLooping = __instance.levelData.miscSettings.GetBool("loopVideo");
@@ -139,4 +136,3 @@ namespace ADOFAIRenderer.Patch.Game
return false; // Skip original UpdateVideo
}
}
}
+2 -3
View File
@@ -1,8 +1,8 @@
using System.Diagnostics.CodeAnalysis;
using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game
{
namespace ADOFAIRenderer.Patch.Game;
[HarmonyPatch(typeof(scrCamera))]
// ReSharper disable once InconsistentNaming
public class scrCameraPatch
@@ -20,4 +20,3 @@ namespace ADOFAIRenderer.Patch.Game
return false;
}
}
}
+2 -3
View File
@@ -1,8 +1,8 @@
using ADOFAIRenderer.Core;
using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game
{
namespace ADOFAIRenderer.Patch.Game;
[HarmonyPatch(typeof(scrController))]
// ReSharper disable once InconsistentNaming
public static class scrControllerPatch
@@ -42,4 +42,3 @@ namespace ADOFAIRenderer.Patch.Game
return !RenderManager.IsActive;
}
}
}
-41
View File
@@ -1,41 +0,0 @@
using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game
{
[HarmonyPatch(typeof(scrDecoration))]
// ReSharper disable once InconsistentNaming
public static class scrDecorationPatch
{
[HarmonyPrefix]
[HarmonyPatch("SetRotation")]
// ReSharper disable once InconsistentNaming
public static bool SetRotationPrefix(scrDecoration __instance)
{
return __instance != null && __instance && __instance.pivotTrans != null;
}
[HarmonyPrefix]
[HarmonyPatch("SetScale")]
// ReSharper disable once InconsistentNaming
public static bool SetScalePrefix(scrDecoration __instance)
{
return __instance != null && __instance && __instance.pivotTrans != null;
}
[HarmonyPrefix]
[HarmonyPatch("SetOpacity")]
// ReSharper disable once InconsistentNaming
public static bool SetOpacityPrefix(scrDecoration __instance)
{
return __instance != null && __instance;
}
[HarmonyPrefix]
[HarmonyPatch("UpdatePosition")]
// ReSharper disable once InconsistentNaming
public static bool UpdatePositionPrefix(scrDecoration __instance)
{
return __instance != null && __instance && __instance.pivotTrans != null;
}
}
}
+3 -4
View File
@@ -1,8 +1,8 @@
using ADOFAIRenderer.Core;
using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game
{
namespace ADOFAIRenderer.Patch.Game;
[HarmonyPatch(typeof(scrFloor))]
// ReSharper disable once InconsistentNaming
public class scrFloorPatch
@@ -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;
}
}
}
+2 -3
View File
@@ -1,8 +1,8 @@
using ADOFAIRenderer.Core;
using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game
{
namespace ADOFAIRenderer.Patch.Game;
[HarmonyPatch(typeof(scrHitTextManager), "ShowHitText")]
// ReSharper disable once InconsistentNaming
public class scrHitTextManagerPatch
@@ -12,4 +12,3 @@ namespace ADOFAIRenderer.Patch.Game
return !RenderManager.IsActive || RenderManager.Instance is { Configuration: { ShowJudgementText: true } };
}
}
}
+2 -3
View File
@@ -1,8 +1,8 @@
using ADOFAIRenderer.Core;
using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game
{
namespace ADOFAIRenderer.Patch.Game;
[HarmonyPatch(typeof(scrMistakesManager))]
// ReSharper disable once InconsistentNaming
public static class scrMistakesManagerPatch
@@ -14,4 +14,3 @@ namespace ADOFAIRenderer.Patch.Game
return !RenderManager.IsActive;
}
}
}
+2 -3
View File
@@ -1,8 +1,8 @@
using ADOFAIRenderer.Core;
using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game
{
namespace ADOFAIRenderer.Patch.Game;
[HarmonyPatch(typeof(scrPlayerManager))]
// ReSharper disable once InconsistentNaming
public static class scrPlayerManagerPatch
@@ -18,4 +18,3 @@ namespace ADOFAIRenderer.Patch.Game
return false;
}
}
}
+3 -4
View File
@@ -2,8 +2,8 @@ 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
@@ -13,7 +13,7 @@ namespace ADOFAIRenderer.Patch.Game
// 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);
@@ -26,4 +26,3 @@ namespace ADOFAIRenderer.Patch.Game
}
}
}
}
+2 -3
View File
@@ -1,8 +1,8 @@
using ADOFAIRenderer.Core;
using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game
{
namespace ADOFAIRenderer.Patch.Game;
[HarmonyPatch(typeof(scrPressToStart))]
// ReSharper disable once InconsistentNaming
public class scrPressToStartPatch
@@ -17,4 +17,3 @@ namespace ADOFAIRenderer.Patch.Game
return false;
}
}
}
+5 -5
View File
@@ -4,8 +4,8 @@ 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
@@ -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..";
@@ -34,4 +35,3 @@ namespace ADOFAIRenderer.Patch.Game
___txt.color = ___defaultColor;
}
}
}
+2 -3
View File
@@ -1,8 +1,8 @@
using ADOFAIRenderer.Core;
using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game
{
namespace ADOFAIRenderer.Patch.Game;
[HarmonyPatch(typeof(scrUIController))]
// ReSharper disable once InconsistentNaming
public class scrUIControllerPatch
@@ -18,4 +18,3 @@ namespace ADOFAIRenderer.Patch.Game
}
}
}
}
+95
View File
@@ -0,0 +1,95 @@
# 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 with API & Data Linking Exception (GPLv3 with Exception, Dynamic Linking Only)**.
- Third-party mods and external tools that reference or dynamically link to ADOFAIRenderer solely via the `ADOFAIRenderer.API` and `ADOFAIRenderer.Data` namespaces are exempt from GPLv3 copyleft requirements and may be distributed under terms of their author's choice. (Static linking is excluded from this exception.)
- For full license terms and exception details, see [LICENSE](LICENSE) and [LICENSE-EXCEPTION.md](LICENSE-EXCEPTION.md).
+15 -42
View File
@@ -1,75 +1,48 @@
using System;
using ADOFAIRenderer;
using ADOFAIRenderer.Core;
using ADOFAIRenderer.Util;
using ADOFAIRenderer.Core.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.3.1-BETA", "ADOFAI Editor Forum")]
[assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.2.3-INDEV", "ADOFAI Editor Forum")]
[assembly: MelonGame("7th Beat Games", "A Dance of Fire and Ice")]
namespace ADOFAIRenderer
{
namespace ADOFAIRenderer;
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 != null) 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();
}
}
}
+24 -63
View File
@@ -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
{
// 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)
{
@@ -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;
@@ -320,4 +282,3 @@ namespace ADOFAIRenderer.UI
return new Color(r, g, b, color.a);
}
}
}
+20 -56
View File
@@ -6,8 +6,8 @@ using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace ADOFAIRenderer.UI
{
namespace ADOFAIRenderer.UI;
internal class DebugMonitorUI : MonoBehaviour
{
// --- Progress Bar UI Configuration Constants ---
@@ -15,14 +15,14 @@ namespace ADOFAIRenderer.UI
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");
@@ -231,4 +196,3 @@ namespace ADOFAIRenderer.UI
if (_progressText) _progressText.text = $"{progress * 100f:F1}% ({currentFloor}/{totalFloors})";
}
}
}
+19 -21
View File
@@ -4,8 +4,8 @@ using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace ADOFAIRenderer.UI
{
namespace ADOFAIRenderer.UI;
internal class EscapeHandlerUI : MonoBehaviour
{
private GameObject _containerObj = null!;
@@ -14,15 +14,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 = 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);
}
}
@@ -128,4 +127,3 @@ namespace ADOFAIRenderer.UI
_escapeStopwatch = null;
}
}
}
+63 -104
View File
@@ -10,8 +10,8 @@ using UnityEngine.UI;
using MelonLoader;
using MelonLoader.Utils;
namespace ADOFAIRenderer.UI
{
namespace ADOFAIRenderer.UI;
internal class FFmpegDownloadUI : MonoBehaviour
{
private GameObject _buttonContainerObj = null!;
@@ -85,57 +85,17 @@ namespace ADOFAIRenderer.UI
}
}
private static TMP_FontAsset GetNanumSquareNeoBdFontAsset()
{
TMP_FontAsset? font = null;
if (RenderMod.FontBundle != null)
{
try
{
font = RenderMod.FontBundle.LoadAsset<TMP_FontAsset>("NanumSquareNeoOTF-Bd SDF");
}
catch
{
// 로딩 실패
}
}
// 폴백 처리
if (font == null)
{
try
{
font = RDString.fontData.fontTMP;
}
catch
{
// ignored
}
}
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 = 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 +104,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>();
@@ -159,18 +119,18 @@ namespace ADOFAIRenderer.UI
bgRect.offsetMin = Vector2.zero;
bgRect.offsetMax = Vector2.zero;
var font = GetNanumSquareNeoBdFontAsset();
var font = ModAssets.NanumSquareNeoNormal;
// 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 +139,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 +158,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 +191,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 +203,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 +233,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 +245,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 +270,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 +282,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 +302,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 +338,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 +371,7 @@ namespace ADOFAIRenderer.UI
private void Update()
{
// 백그라운드 스레드에서 메인 스레드로 넘겨진 작업 순차 실행
// Execute queued actions on main thread
while (_mainThreadQueue.TryDequeue(out var action))
{
action?.Invoke();
@@ -428,20 +388,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 +421,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 +446,7 @@ namespace ADOFAIRenderer.UI
}
catch (OperationCanceledException)
{
// 취소 시 임시 파일 정리
// Clean up temporary file on cancellation
if (!string.IsNullOrEmpty(tempZipPath) && File.Exists(tempZipPath))
{
try { File.Delete(tempZipPath); }
@@ -495,22 +455,22 @@ 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;
_buttonContainerObj.SetActive(true);
var downloadBtnText = _buttonContainerObj.transform.Find("DownloadButton/DownloadText")?.GetComponent<TextMeshProUGUI>();
if (downloadBtnText != null)
if (downloadBtnText)
{
downloadBtnText.text = "재시도 (Retry)";
downloadBtnText.text = "Retry";
}
});
}
@@ -530,7 +490,7 @@ namespace ADOFAIRenderer.UI
{
ExecuteOnMainThread(() =>
{
if (_statusText != null)
if (_statusText)
{
_statusText.text = message;
_statusText.color = color;
@@ -542,11 +502,11 @@ namespace ADOFAIRenderer.UI
{
ExecuteOnMainThread(() =>
{
if (_fillRect != null)
if (_fillRect)
{
_fillRect.anchorMax = new Vector2(percent, 1f);
}
if (_progressText != null)
if (_progressText)
{
_progressText.text = text;
}
@@ -556,7 +516,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 +571,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 +600,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 +613,7 @@ namespace ADOFAIRenderer.UI
File.Copy(soFile, targetPath, true);
}
// 임시 디렉토리 정리
// Clean up temporary directory
try
{
Directory.Delete(tempExtractDir, true);
@@ -675,7 +635,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}");
}
}
}
@@ -690,4 +650,3 @@ namespace ADOFAIRenderer.UI
}
}
}
}
File diff suppressed because it is too large. Load diff
+109
View File
@@ -0,0 +1,109 @@
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
{
public static RendererUIManager Instance = null!;
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(1.000f, 0.294f, 0.079f, 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();
}
}
File diff suppressed because it is too large. Load diff
+2 -3
View File
@@ -1,7 +1,7 @@
using System;
namespace ADOFAIRenderer.Util.Audio
{
namespace ADOFAIRenderer.Util.Audio;
internal static class AudioResampler
{
public static float[] Resample(float[] inputSamples, int inputChannels, int inputFrequency, int targetChannels, int targetFrequency)
@@ -98,4 +98,3 @@ namespace ADOFAIRenderer.Util.Audio
return outputSamples;
}
}
}
+4 -3
View File
@@ -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
{
// 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();
@@ -237,4 +239,3 @@ namespace ADOFAIRenderer.Util.Audio
}
}
}
}
+2 -3
View File
@@ -1,7 +1,7 @@
using System;
namespace ADOFAIRenderer.Util.Audio
{
namespace ADOFAIRenderer.Util.Audio;
internal class AudioSyncCalculator
{
private readonly long _sampleRate;
@@ -38,4 +38,3 @@ namespace ADOFAIRenderer.Util.Audio
return (int)samplesNeeded;
}
}
}
+2 -3
View File
@@ -1,7 +1,7 @@
using System;
namespace ADOFAIRenderer.Util.Audio.Filter
{
namespace ADOFAIRenderer.Util.Audio.Filter;
internal class Gain : IAudioFilter
{
public float Level = 0f;
@@ -21,4 +21,3 @@ namespace ADOFAIRenderer.Util.Audio.Filter
public double Latency => 0.0;
}
}
+2 -3
View File
@@ -1,8 +1,8 @@
using System;
using System.Runtime.CompilerServices;
namespace ADOFAIRenderer.Util.Audio.Filter
{
namespace ADOFAIRenderer.Util.Audio.Filter;
internal class HardClip : IAudioFilter
{
// ReSharper disable once MemberCanBePrivate.Global
@@ -30,4 +30,3 @@ namespace ADOFAIRenderer.Util.Audio.Filter
public double Latency => 0.0;
}
}
+2 -3
View File
@@ -1,7 +1,7 @@
using System;
namespace ADOFAIRenderer.Util.Audio.Filter
{
namespace ADOFAIRenderer.Util.Audio.Filter;
internal interface IAudioFilter
{
void Initialize(int sampleRate, int channels);
@@ -13,4 +13,3 @@ namespace ADOFAIRenderer.Util.Audio.Filter
double Latency { get; }
}
}
+4 -5
View File
@@ -1,8 +1,8 @@
using System;
using System.Runtime.CompilerServices;
namespace ADOFAIRenderer.Util.Audio.Filter
{
namespace ADOFAIRenderer.Util.Audio.Filter;
// ReSharper disable once InconsistentNaming
internal class RMSCompressor : IAudioFilter
{
@@ -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);
}
}
}
+2 -3
View File
@@ -5,8 +5,8 @@ using ADOFAIRenderer.Util.Audio.Filter;
using MelonLoader;
using UnityEngine;
namespace ADOFAIRenderer.Util.Audio
{
namespace ADOFAIRenderer.Util.Audio;
internal class AudioClipCache
{
private readonly Dictionary<string, CachedClipData> _cache = new();
@@ -414,4 +414,3 @@ namespace ADOFAIRenderer.Util.Audio
}
}
}
}
+4 -5
View File
@@ -4,8 +4,8 @@ using System.Resources;
using MelonLoader;
using UnityEngine;
namespace ADOFAIRenderer.Util
{
namespace ADOFAIRenderer.Util;
internal static class BundleLoader
{
private static readonly string BundlePlatform;
@@ -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.");
}
}
}
-10
View File
@@ -1,10 +0,0 @@
using System.Diagnostics.CodeAnalysis;
namespace ADOFAIRenderer.Util.FFmpeg
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public enum Codec
{
H264, H265, VP9, AV1, VVC
}
}
+71 -71
View File
@@ -3,11 +3,11 @@ using FFmpeg.AutoGen;
using Unity.Collections;
using Unity.Collections.LowLevel.Unsafe;
namespace ADOFAIRenderer.Util.FFmpeg
{
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()
@@ -859,4 +860,3 @@ namespace ADOFAIRenderer.Util.FFmpeg
Dispose();
}
}
}
File diff suppressed because it is too large. Load diff
-12
View File
@@ -1,12 +0,0 @@
using System.Diagnostics.CodeAnalysis;
namespace ADOFAIRenderer.Util.FFmpeg
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public enum RateControlMode
{
CBR,
VBR,
CQP
}
}
+4 -4
View File
@@ -2,11 +2,12 @@ using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using ADOFAIRenderer.Data;
namespace ADOFAIRenderer.Util.FFmpeg;
namespace ADOFAIRenderer.Util.FFmpeg
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public enum EncoderKind
internal enum EncoderKind
{
Software,
NVENC,
@@ -110,4 +111,3 @@ namespace ADOFAIRenderer.Util.FFmpeg
public override string ToString() => Name;
}
}
-517
View File
@@ -1,517 +0,0 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using ADOFAIRenderer.Util.FFmpeg;
using MelonLoader;
using UnityEngine;
using FFmpeg.AutoGen;
namespace ADOFAIRenderer.Util
{
// ReSharper disable once InconsistentNaming
internal class FFmpegVideoBGPlayer : MonoBehaviour
{
private static readonly int YTex = Shader.PropertyToID("_YTex");
private static readonly int UVTex = Shader.PropertyToID("_UVTex");
private static readonly int Result = Shader.PropertyToID("_Result");
private static readonly int Width = Shader.PropertyToID("Width");
private static readonly int Height = Shader.PropertyToID("Height");
private static readonly int UTex = Shader.PropertyToID("_UTex");
private static readonly int VTex = Shader.PropertyToID("_VTex");
private static readonly int ColorRange = Shader.PropertyToID("ColorRange");
[SuppressMessage("ReSharper", "InconsistentNaming")]
private enum GPUMode
{
None,
YUV420P,
NV12
}
private FFmpegNativeDecoder? _decoder;
// CPU fallback rendering
// ReSharper disable once InconsistentNaming
private Texture2D? _textureRGBA;
// GPU conversion textures
private Texture2D? _textureY;
private Texture2D? _textureU;
private Texture2D? _textureV;
private Texture2D? _textureUV;
// Compute Shader variables
private ComputeShader? _computeShader;
private RenderTexture? _renderTexture;
private int _kernelIndex = -1;
private double _currentFrameTimestamp;
private double _nextFrameTimestamp;
private GPUMode _gpuMode = GPUMode.None;
private RenderTexture? _targetTexture;
private bool _isLooping;
private bool _isInitialized;
private GameObject? _quad;
private Material? _material;
// ReSharper disable once InconsistentNaming
public void Init(string videoPath, RenderTexture? targetTexture, bool loop, Camera? targetCamera, GameObject videoBGGo)
{
CleanUp();
_targetTexture = targetTexture;
_isLooping = loop;
if (_targetTexture == null && targetCamera != null)
{
SetupBackgroundQuad(targetCamera, videoBGGo);
}
if (!File.Exists(videoPath))
{
Melon<RenderMod>.Logger.Warning($"[FFmpegVideoBGPlayer] 비디오 파일이 존재하지 않습니다: {videoPath}");
return;
}
try
{
_decoder = new FFmpegNativeDecoder(videoPath);
_computeShader = RenderMod.ColorConversionShader;
var w = _decoder.Width;
var h = _decoder.Height;
var pixFmt = _decoder.PixelFormat;
var gpuInitSuccess = false;
if (_computeShader != null)
{
switch (pixFmt)
{
case AVPixelFormat.AV_PIX_FMT_NV12:
try
{
_kernelIndex = _computeShader.FindKernel("CSMain_NV12ToRGBA");
_textureY = new Texture2D(w, h, TextureFormat.R8, false);
_textureUV = new Texture2D(w / 2, h / 2, TextureFormat.RG16, false);
ConfigureTexture(_textureY);
ConfigureTexture(_textureUV);
_gpuMode = GPUMode.NV12;
gpuInitSuccess = true;
Melon<RenderMod>.Logger.Msg($"[FFmpegVideoBGPlayer] Compute Shader NV12 모드 초기화 완료");
}
catch
{
// ignored
}
break;
case AVPixelFormat.AV_PIX_FMT_YUV420P:
case AVPixelFormat.AV_PIX_FMT_YUVJ420P:
try
{
_kernelIndex = _computeShader.FindKernel("CSMain_YUV420PToRGBA");
_textureY = new Texture2D(w, h, TextureFormat.R8, false);
_textureU = new Texture2D(w / 2, h / 2, TextureFormat.R8, false);
_textureV = new Texture2D(w / 2, h / 2, TextureFormat.R8, false);
ConfigureTexture(_textureY);
ConfigureTexture(_textureU);
ConfigureTexture(_textureV);
_gpuMode = GPUMode.YUV420P;
gpuInitSuccess = true;
Melon<RenderMod>.Logger.Msg($"[FFmpegVideoBGPlayer] Compute Shader YUV420P 모드 초기화 완료");
}
catch
{
// ignored
}
break;
default:
Melon<RenderMod>.Logger.Msg("[FFmpegVideoBGPlayer] 색공간 변환에 Compute Shader를 사용할 수 없습니다.");
break;
}
}
if (gpuInitSuccess && _computeShader != null)
{
_renderTexture = new RenderTexture(w, h, 0, RenderTextureFormat.ARGB32)
{
enableRandomWrite = true
};
_renderTexture.Create();
}
else
{
_textureRGBA = new Texture2D(w, h, TextureFormat.RGBA32, false);
ConfigureTexture(_textureRGBA);
_gpuMode = GPUMode.None;
Melon<RenderMod>.Logger.Warning($"[FFmpegVideoBGPlayer] GPU 가속 미지원 또는 로드 실패로 CPU 폴백(RGBA)으로 진행합니다.");
}
_currentFrameTimestamp = 0.0;
_nextFrameTimestamp = 0.0;
_isInitialized = true;
Melon<RenderMod>.Logger.Msg($"[FFmpegVideoBGPlayer] 초기화 성공 - 해상도: {w}x{h}, FPS: {_decoder.FPS:F2}");
}
catch (Exception ex)
{
Melon<RenderMod>.Logger.Error($"[FFmpegVideoBGPlayer] 초기화 중 에러 발생: {ex}");
CleanUp();
}
}
private static void ConfigureTexture(Texture2D tex)
{
tex.wrapMode = TextureWrapMode.Clamp;
tex.filterMode = FilterMode.Bilinear;
}
private void LateUpdate()
{
if (!_isInitialized || _decoder == null) return;
var cond = scrConductor.instance;
if (!cond || !cond.hasSongStarted) return;
var pitch = cond.song.pitch;
var separateCountdownOffset = cond.separateCountdownTime ?
cond.crotchetAtStart * cond.adjustedCountdownTicks : 0.0;
var songTime = (cond.dspTime - cond.dspTimeSong) * pitch - cond.addoffset - separateCountdownOffset;
var vidOffset = scrVfxPlus.instance ? scrVfxPlus.instance.vidOffset : 0.0;
var targetVideoTime = songTime + vidOffset;
if (targetVideoTime < 0)
{
if (_targetTexture)
{
var active = RenderTexture.active;
RenderTexture.active = _targetTexture;
GL.Clear(true, true, Color.black);
RenderTexture.active = active;
}
if (_quad)
{
_quad.SetActive(false);
}
return;
}
if (ADOBase.customLevel)
{
ADOBase.customLevel.ShowTutorialBackground(false);
}
try
{
if (targetVideoTime < _currentFrameTimestamp || targetVideoTime - _currentFrameTimestamp > 1.0)
{
_decoder.SeekToTime(targetVideoTime);
_currentFrameTimestamp = targetVideoTime;
_nextFrameTimestamp = targetVideoTime;
}
bool frameUpdated = false;
while (targetVideoTime >= _nextFrameTimestamp)
{
double timestamp = 0.0;
bool success = false;
if (_gpuMode == GPUMode.NV12 && _textureY && _textureUV)
{
success = _decoder.TryReadNV12Frame(
_textureY.GetRawTextureData<byte>(),
_textureUV.GetRawTextureData<byte>(),
out timestamp
);
}
else if (_gpuMode == GPUMode.YUV420P && _textureY && _textureU && _textureV)
{
success = _decoder.TryReadYUV420PFrame(
_textureY.GetRawTextureData<byte>(),
_textureU.GetRawTextureData<byte>(),
_textureV.GetRawTextureData<byte>(),
out timestamp
);
}
else
{
if (_textureRGBA != null)
{
success = _decoder.TryReadFrame(
_textureRGBA.GetRawTextureData<byte>(),
vflip: true,
out timestamp
);
}
}
if (success)
{
_currentFrameTimestamp = timestamp;
_nextFrameTimestamp = timestamp + (1.0 / _decoder.FPS);
frameUpdated = true;
}
else
{
if (_isLooping)
{
_decoder.SeekToTime(0);
_currentFrameTimestamp = 0;
_nextFrameTimestamp = 1.0 / _decoder.FPS;
break;
}
else
{
break;
}
}
}
if (frameUpdated)
{
RenderToTarget();
}
}
catch (Exception ex)
{
Melon<RenderMod>.Logger.Error($"[FFmpegVideoBGPlayer] Update 재생 중 에러 발생: {ex}");
_isInitialized = false;
}
}
private void RenderToTarget()
{
if (_decoder == null) return;
int w = _decoder.Width;
int h = _decoder.Height;
if (_gpuMode == GPUMode.NV12 && _textureY && _textureUV && _computeShader && _renderTexture)
{
_textureY.Apply();
_textureUV.Apply();
_computeShader.SetTexture(_kernelIndex, YTex, _textureY);
_computeShader.SetTexture(_kernelIndex, UVTex, _textureUV);
_computeShader.SetTexture(_kernelIndex, Result, _renderTexture);
_computeShader.SetInt(Width, w);
_computeShader.SetInt(Height, h);
_computeShader.SetInt(ColorRange, _decoder.ColorRange == AVColorRange.AVCOL_RANGE_JPEG ? 1 : 0);
int threadGroupsX = (w + 7) / 8;
int threadGroupsY = (h + 7) / 8;
_computeShader.Dispatch(_kernelIndex, threadGroupsX, threadGroupsY, 1);
if (_targetTexture)
{
Graphics.Blit(_renderTexture, _targetTexture);
}
else if (_material)
{
if (_material.mainTexture != _renderTexture)
{
_material.mainTexture = _renderTexture;
}
if (_quad != null && !_quad.activeSelf)
{
_quad.SetActive(true);
}
}
}
else if (_gpuMode == GPUMode.YUV420P && _textureY && _textureU && _textureV && _computeShader && _renderTexture)
{
_textureY.Apply();
_textureU.Apply();
_textureV.Apply();
_computeShader.SetTexture(_kernelIndex, YTex, _textureY);
_computeShader.SetTexture(_kernelIndex, UTex, _textureU);
_computeShader.SetTexture(_kernelIndex, VTex, _textureV);
_computeShader.SetTexture(_kernelIndex, Result, _renderTexture);
_computeShader.SetInt(Width, w);
_computeShader.SetInt(Height, h);
_computeShader.SetInt(ColorRange, _decoder.ColorRange == AVColorRange.AVCOL_RANGE_JPEG ? 1 : 0);
int threadGroupsX = (w + 7) / 8;
int threadGroupsY = (h + 7) / 8;
_computeShader.Dispatch(_kernelIndex, threadGroupsX, threadGroupsY, 1);
if (_targetTexture)
{
Graphics.Blit(_renderTexture, _targetTexture);
}
else if (_material)
{
if (_material.mainTexture != _renderTexture)
{
_material.mainTexture = _renderTexture;
}
if (_quad != null && !_quad.activeSelf)
{
_quad.SetActive(true);
}
}
}
else if (_textureRGBA)
{
_textureRGBA.Apply();
if (_targetTexture)
{
Graphics.Blit(_textureRGBA, _targetTexture);
}
else if (_material)
{
if (_material.mainTexture != _textureRGBA)
{
_material.mainTexture = _textureRGBA;
}
if (_quad != null && !_quad.activeSelf)
{
_quad.SetActive(true);
}
}
}
}
private void OnDestroy()
{
CleanUp();
}
public void CleanUp()
{
_isInitialized = false;
if (_decoder != null)
{
_decoder.Dispose();
_decoder = null;
Melon<RenderMod>.Logger.Msg("[FFmpegVideoBGPlayer] 디코더 해제 완료.");
}
if (_textureRGBA != null)
{
Destroy(_textureRGBA);
_textureRGBA = null;
}
if (_textureY != null) Destroy(_textureY);
if (_textureU != null) Destroy(_textureU);
if (_textureV != null) Destroy(_textureV);
if (_textureUV != null) Destroy(_textureUV);
_textureY = null;
_textureU = null;
_textureV = null;
_textureUV = null;
if (_renderTexture != null)
{
_renderTexture.Release();
Destroy(_renderTexture);
_renderTexture = null;
}
if (_quad != null)
{
Destroy(_quad);
_quad = null;
}
if (_material != null)
{
Destroy(_material);
_material = null;
}
}
// ReSharper disable once InconsistentNaming
private void SetupBackgroundQuad(Camera targetCamera, GameObject videoBGGo)
{
_quad = GameObject.CreatePrimitive(PrimitiveType.Quad);
_quad.name = "FFmpegVideoBGQuad";
// ReSharper disable once Unity.UnresolvedComponentOrScriptableObject
var col = _quad.GetComponent("MeshCollider");
if (col) Destroy(col);
_quad.transform.parent = targetCamera.transform;
var zPos = targetCamera.farClipPlane - 0.5f;
_quad.transform.localPosition = new Vector3(0f, 0f, zPos);
_quad.transform.localRotation = Quaternion.identity;
float height;
float width;
if (targetCamera.orthographic)
{
height = 2.0f * targetCamera.orthographicSize;
width = height * targetCamera.aspect;
}
else
{
height = 2.0f * zPos * Mathf.Tan(targetCamera.fieldOfView * 0.5f * Mathf.Deg2Rad);
width = height * targetCamera.aspect;
}
_quad.transform.localScale = new Vector3(width, height, 1.0f);
_quad.layer = videoBGGo.layer;
// Set vertex colors to white so Sprites/Default works properly (otherwise it may draw transparently)
var meshFilter = _quad.GetComponent<MeshFilter>();
if (meshFilter != null && meshFilter.mesh != null)
{
var mesh = meshFilter.mesh;
var colors = new Color[mesh.vertexCount];
for (var i = 0; i < colors.Length; i++)
{
colors[i] = Color.white;
}
mesh.colors = colors;
}
Shader? shader = null;
if (scrCamera.instance != null)
{
try
{
var camQuadMeshField = typeof(scrCamera).GetField("camQuadMesh", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
if (camQuadMeshField != null)
{
var renderer = camQuadMeshField.GetValue(scrCamera.instance) as MeshRenderer;
if (renderer != null && renderer.sharedMaterial != null)
{
shader = renderer.sharedMaterial.shader;
}
}
}
catch (Exception)
{
// ignored
}
}
if (shader == null) shader = Shader.Find("Sprites/Default");
if (shader == null) shader = Shader.Find("Unlit/Texture");
if (shader == null) shader = Shader.Find("Standard");
_material = new Material(shader)
{
color = Color.white
};
_quad.GetComponent<MeshRenderer>().material = _material;
// Set inactive initially to prevent white flash
_quad.SetActive(false);
}
}
}
+11
View File
@@ -0,0 +1,11 @@
using System.IO;
namespace ADOFAIRenderer.Util;
internal static class FileUtil
{
public static void CreateDirectory(string path)
{
if (!Directory.Exists(path)) Directory.CreateDirectory(path);
}
}
+615
View File
@@ -0,0 +1,615 @@
using System;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using MelonLoader;
namespace ADOFAIRenderer.Util;
[SuppressMessage("ReSharper", "InconsistentNaming")]
[SuppressMessage("ReSharper", "UnusedMember.Local")]
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)
{
// 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 var dialog);
if (createHr != 0)
{
// 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)
{
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);
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, _, 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
}
+36 -53
View File
@@ -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,72 +71,55 @@ namespace ADOFAIRenderer.Util
state.IsInFlight = true;
}
// GPU 컬러 변환 활성화 여부 검사
bool useGPUConversion = RenderMod.ColorConversionShader;
AVPixelFormat expectedFmt = AVPixelFormat.AV_PIX_FMT_RGBA;
// Check if GPU color conversion is available
var expectedFmt = AVPixelFormat.AV_PIX_FMT_RGBA;
if (useGPUConversion)
var kernelIndex = -1;
if (ModAssets.ColorConversionShader)
{
expectedFmt = _targetFormat;
// Compute Shader는 NV12와 YUV420P만 지원하므로 그 외 포맷은 CPU 폴백 처리
if (expectedFmt != AVPixelFormat.AV_PIX_FMT_NV12 && expectedFmt != AVPixelFormat.AV_PIX_FMT_YUV420P)
var kernalName = _targetFormat switch
{
useGPUConversion = false;
}
AVPixelFormat.AV_PIX_FMT_NV12 => "CSMain_NV12",
AVPixelFormat.AV_PIX_FMT_YUV420P => "CSMain_YUV420P",
_ => null
};
if (kernalName != null)
kernelIndex = ModAssets.ColorConversionShader.FindKernel(kernalName);
}
var useGPUConversion = kernelIndex >= 0;
if (useGPUConversion) expectedFmt = _targetFormat;
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");
}
else if (expectedFmt == AVPixelFormat.AV_PIX_FMT_YUV420P)
{
kernelIndex = RenderMod.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(
AsyncGPUReadback.RequestIntoNativeArray(
ref state.NativeBuffer, state.GPUBuffer, state.CachedCallback
);
/*AsyncGPUReadback.Request(
state.GPUBuffer,
state.CachedCallback
);
}
else
{
// 커널을 못 찾은 경우 기존 RGBA 텍스처 백업 기법으로 폴백
AsyncGPUReadback.RequestIntoNativeArray(
ref state.NativeBuffer,
texture,
0,
state.CachedCallback
);
}
);*/
}
else
{
AsyncGPUReadback.RequestIntoNativeArray(
ref state.NativeBuffer,
texture,
0,
state.CachedCallback
ref state.NativeBuffer, texture, 0, state.CachedCallback
);
}
}
@@ -168,16 +152,14 @@ namespace ADOFAIRenderer.Util
}
Melon<RenderMod>.Logger.Warning($"[OOM Prevent] GPU Readback failed for frame {frame}. Queue deadlock prevented by sending a black frame.");
var byteSize = state.IsGPUConversion
? state.Width * state.Height * 3 / 2
: state.Width * state.Height * 4;
var byteSize = state.Width * state.Height * 3 / 2;
var blackFrame = ArrayPool<byte>.Shared.Rent(byteSize);
Array.Clear(blackFrame, 0, byteSize);
var capFrame = new RenderManager.CapturedFrame
{
ManagedBuffer = blackFrame,
Length = byteSize
ManagedBuffer = blackFrame, Length = byteSize,
IsColorConverted = true
};
PushToQueue(frame, capFrame);
RecycleState(state);
@@ -186,19 +168,20 @@ namespace ADOFAIRenderer.Util
{
unsafe
{
/*
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);
}
*/
var ptr = (IntPtr)state.NativeBuffer.GetUnsafeReadOnlyPtr();
var capFrame = new RenderManager.CapturedFrame
{
NativePointer = ptr,
Length = state.NativeBuffer.Length,
ReadbackState = state
NativePointer = ptr, Length = state.NativeBuffer.Length,
ReadbackState = state, IsColorConverted = state.IsGPUConversion
};
PushToQueue(frame, capFrame);
}
+4 -4
View File
@@ -2,12 +2,13 @@ using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text.RegularExpressions;
using ADOFAIRenderer.Data;
using ADOFAIRenderer.Util.FFmpeg;
using MelonLoader;
using UnityEngine;
namespace ADOFAIRenderer.Util
{
namespace ADOFAIRenderer.Util;
[SuppressMessage("ReSharper", "InconsistentNaming")]
internal enum GPUVendor
{
@@ -20,7 +21,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 =
@@ -150,4 +151,3 @@ namespace ADOFAIRenderer.Util
return prioritized;
}
}
}
+11 -11
View File
@@ -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
View File
@@ -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);
+2
View File
@@ -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)
-29
View File
@@ -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}");
}
}
}
}
}
+517
View File
@@ -0,0 +1,517 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using ADOFAIRenderer.UI;
using ADOFAIRenderer.Util.FFmpeg;
using MelonLoader;
using UnityEngine;
using FFmpeg.AutoGen;
namespace ADOFAIRenderer.Util;
// ReSharper disable once InconsistentNaming
internal class VideoBGPlayer : MonoBehaviour
{
private static readonly int YTex = Shader.PropertyToID("_YTex");
private static readonly int UVTex = Shader.PropertyToID("_UVTex");
private static readonly int Result = Shader.PropertyToID("_Result");
private static readonly int Width = Shader.PropertyToID("Width");
private static readonly int Height = Shader.PropertyToID("Height");
private static readonly int UTex = Shader.PropertyToID("_UTex");
private static readonly int VTex = Shader.PropertyToID("_VTex");
private static readonly int ColorRange = Shader.PropertyToID("ColorRange");
[SuppressMessage("ReSharper", "InconsistentNaming")]
private enum GPUMode
{
None,
YUV420P,
NV12
}
private FFmpegNativeDecoder? _decoder;
// CPU fallback rendering
// ReSharper disable once InconsistentNaming
private Texture2D? _textureRGBA;
// GPU conversion textures
private Texture2D? _textureY;
private Texture2D? _textureU;
private Texture2D? _textureV;
private Texture2D? _textureUV;
// Compute Shader variables
private ComputeShader? _computeShader;
private RenderTexture? _renderTexture;
private int _kernelIndex = -1;
private double _currentFrameTimestamp;
private double _nextFrameTimestamp;
private GPUMode _gpuMode = GPUMode.None;
private RenderTexture? _targetTexture;
private bool _isLooping;
private bool _isInitialized;
private GameObject? _quad;
private Material? _material;
// ReSharper disable once InconsistentNaming
public void Init(string videoPath, RenderTexture? targetTexture, bool loop, Camera? targetCamera, GameObject videoBGGo)
{
CleanUp();
_targetTexture = targetTexture;
_isLooping = loop;
if (_targetTexture == null && targetCamera != null)
{
SetupBackgroundQuad(targetCamera, videoBGGo);
}
if (!File.Exists(videoPath))
{
Melon<RenderMod>.Logger.Warning($"[FFmpegVideoBGPlayer] Video file does not exist: {videoPath}");
return;
}
try
{
_decoder = new FFmpegNativeDecoder(videoPath);
_computeShader = ModAssets.ColorConversionShader;
var w = _decoder.Width;
var h = _decoder.Height;
var pixFmt = _decoder.PixelFormat;
var gpuInitSuccess = false;
if (_computeShader != null)
{
switch (pixFmt)
{
case AVPixelFormat.AV_PIX_FMT_NV12:
try
{
_kernelIndex = _computeShader.FindKernel("CSMain_NV12ToRGBA");
_textureY = new Texture2D(w, h, TextureFormat.R8, false);
_textureUV = new Texture2D(w / 2, h / 2, TextureFormat.RG16, false);
ConfigureTexture(_textureY);
ConfigureTexture(_textureUV);
_gpuMode = GPUMode.NV12;
gpuInitSuccess = true;
Melon<RenderMod>.Logger.Msg("[FFmpegVideoBGPlayer] Initialized Compute Shader NV12 mode successfully");
}
catch
{
// ignored
}
break;
case AVPixelFormat.AV_PIX_FMT_YUV420P:
case AVPixelFormat.AV_PIX_FMT_YUVJ420P:
try
{
_kernelIndex = _computeShader.FindKernel("CSMain_YUV420PToRGBA");
_textureY = new Texture2D(w, h, TextureFormat.R8, false);
_textureU = new Texture2D(w / 2, h / 2, TextureFormat.R8, false);
_textureV = new Texture2D(w / 2, h / 2, TextureFormat.R8, false);
ConfigureTexture(_textureY);
ConfigureTexture(_textureU);
ConfigureTexture(_textureV);
_gpuMode = GPUMode.YUV420P;
gpuInitSuccess = true;
Melon<RenderMod>.Logger.Msg("[FFmpegVideoBGPlayer] Initialized Compute Shader YUV420P mode successfully");
}
catch
{
// ignored
}
break;
default:
Melon<RenderMod>.Logger.Msg("[FFmpegVideoBGPlayer] Cannot use Compute Shader for colorspace conversion.");
break;
}
}
if (gpuInitSuccess && _computeShader != null)
{
_renderTexture = new RenderTexture(w, h, 0, RenderTextureFormat.ARGB32)
{
enableRandomWrite = true
};
_renderTexture.Create();
}
else
{
_textureRGBA = new Texture2D(w, h, TextureFormat.RGBA32, false);
ConfigureTexture(_textureRGBA);
_gpuMode = GPUMode.None;
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] Successfully initialized - Resolution: {w}x{h}, FPS: {_decoder.FPS:F2}");
}
catch (Exception ex)
{
Melon<RenderMod>.Logger.Error($"[FFmpegVideoBGPlayer] Error during initialization: {ex}");
CleanUp();
}
}
private static void ConfigureTexture(Texture2D tex)
{
tex.wrapMode = TextureWrapMode.Clamp;
tex.filterMode = FilterMode.Bilinear;
}
private void LateUpdate()
{
if (!_isInitialized || _decoder == null) return;
var cond = scrConductor.instance;
if (!cond || !cond.hasSongStarted) return;
var pitch = cond.song.pitch;
var separateCountdownOffset = cond.separateCountdownTime ?
cond.crotchetAtStart * cond.adjustedCountdownTicks : 0.0;
var songTime = (cond.dspTime - cond.dspTimeSong) * pitch - cond.addoffset - separateCountdownOffset;
var vidOffset = scrVfxPlus.instance ? scrVfxPlus.instance.vidOffset : 0.0;
var targetVideoTime = songTime + vidOffset;
if (targetVideoTime < 0)
{
if (_targetTexture)
{
var active = RenderTexture.active;
RenderTexture.active = _targetTexture;
GL.Clear(true, true, Color.black);
RenderTexture.active = active;
}
if (_quad)
{
_quad.SetActive(false);
}
return;
}
if (ADOBase.customLevel)
{
ADOBase.customLevel.ShowTutorialBackground(false);
}
try
{
if (targetVideoTime < _currentFrameTimestamp || targetVideoTime - _currentFrameTimestamp > 1.0)
{
_decoder.SeekToTime(targetVideoTime);
_currentFrameTimestamp = targetVideoTime;
_nextFrameTimestamp = targetVideoTime;
}
bool frameUpdated = false;
while (targetVideoTime >= _nextFrameTimestamp)
{
double timestamp = 0.0;
bool success = false;
if (_gpuMode == GPUMode.NV12 && _textureY && _textureUV)
{
success = _decoder.TryReadNV12Frame(
_textureY.GetRawTextureData<byte>(),
_textureUV.GetRawTextureData<byte>(),
out timestamp
);
}
else if (_gpuMode == GPUMode.YUV420P && _textureY && _textureU && _textureV)
{
success = _decoder.TryReadYUV420PFrame(
_textureY.GetRawTextureData<byte>(),
_textureU.GetRawTextureData<byte>(),
_textureV.GetRawTextureData<byte>(),
out timestamp
);
}
else
{
if (_textureRGBA != null)
{
success = _decoder.TryReadFrame(
_textureRGBA.GetRawTextureData<byte>(),
vflip: true,
out timestamp
);
}
}
if (success)
{
_currentFrameTimestamp = timestamp;
_nextFrameTimestamp = timestamp + (1.0 / _decoder.FPS);
frameUpdated = true;
}
else
{
if (_isLooping)
{
_decoder.SeekToTime(0);
_currentFrameTimestamp = 0;
_nextFrameTimestamp = 1.0 / _decoder.FPS;
break;
}
else
{
break;
}
}
}
if (frameUpdated)
{
RenderToTarget();
}
}
catch (Exception ex)
{
Melon<RenderMod>.Logger.Error($"[FFmpegVideoBGPlayer] Error during playback update: {ex}");
_isInitialized = false;
}
}
private void RenderToTarget()
{
if (_decoder == null) return;
int w = _decoder.Width;
int h = _decoder.Height;
if (_gpuMode == GPUMode.NV12 && _textureY && _textureUV && _computeShader && _renderTexture)
{
_textureY.Apply();
_textureUV.Apply();
_computeShader.SetTexture(_kernelIndex, YTex, _textureY);
_computeShader.SetTexture(_kernelIndex, UVTex, _textureUV);
_computeShader.SetTexture(_kernelIndex, Result, _renderTexture);
_computeShader.SetInt(Width, w);
_computeShader.SetInt(Height, h);
_computeShader.SetInt(ColorRange, _decoder.ColorRange == AVColorRange.AVCOL_RANGE_JPEG ? 1 : 0);
int threadGroupsX = (w + 7) / 8;
int threadGroupsY = (h + 7) / 8;
_computeShader.Dispatch(_kernelIndex, threadGroupsX, threadGroupsY, 1);
if (_targetTexture)
{
Graphics.Blit(_renderTexture, _targetTexture);
}
else if (_material)
{
if (_material.mainTexture != _renderTexture)
{
_material.mainTexture = _renderTexture;
}
if (_quad != null && !_quad.activeSelf)
{
_quad.SetActive(true);
}
}
}
else if (_gpuMode == GPUMode.YUV420P && _textureY && _textureU && _textureV && _computeShader && _renderTexture)
{
_textureY.Apply();
_textureU.Apply();
_textureV.Apply();
_computeShader.SetTexture(_kernelIndex, YTex, _textureY);
_computeShader.SetTexture(_kernelIndex, UTex, _textureU);
_computeShader.SetTexture(_kernelIndex, VTex, _textureV);
_computeShader.SetTexture(_kernelIndex, Result, _renderTexture);
_computeShader.SetInt(Width, w);
_computeShader.SetInt(Height, h);
_computeShader.SetInt(ColorRange, _decoder.ColorRange == AVColorRange.AVCOL_RANGE_JPEG ? 1 : 0);
int threadGroupsX = (w + 7) / 8;
int threadGroupsY = (h + 7) / 8;
_computeShader.Dispatch(_kernelIndex, threadGroupsX, threadGroupsY, 1);
if (_targetTexture)
{
Graphics.Blit(_renderTexture, _targetTexture);
}
else if (_material)
{
if (_material.mainTexture != _renderTexture)
{
_material.mainTexture = _renderTexture;
}
if (_quad != null && !_quad.activeSelf)
{
_quad.SetActive(true);
}
}
}
else if (_textureRGBA)
{
_textureRGBA.Apply();
if (_targetTexture)
{
Graphics.Blit(_textureRGBA, _targetTexture);
}
else if (_material)
{
if (_material.mainTexture != _textureRGBA)
{
_material.mainTexture = _textureRGBA;
}
if (_quad != null && !_quad.activeSelf)
{
_quad.SetActive(true);
}
}
}
}
private void OnDestroy()
{
CleanUp();
}
public void CleanUp()
{
_isInitialized = false;
if (_decoder != null)
{
_decoder.Dispose();
_decoder = null;
Melon<RenderMod>.Logger.Msg("[FFmpegVideoBGPlayer] Decoder successfully disposed.");
}
if (_textureRGBA != null)
{
Destroy(_textureRGBA);
_textureRGBA = null;
}
if (_textureY != null) Destroy(_textureY);
if (_textureU != null) Destroy(_textureU);
if (_textureV != null) Destroy(_textureV);
if (_textureUV != null) Destroy(_textureUV);
_textureY = null;
_textureU = null;
_textureV = null;
_textureUV = null;
if (_renderTexture != null)
{
_renderTexture.Release();
Destroy(_renderTexture);
_renderTexture = null;
}
if (_quad != null)
{
Destroy(_quad);
_quad = null;
}
if (_material != null)
{
Destroy(_material);
_material = null;
}
}
// ReSharper disable once InconsistentNaming
private void SetupBackgroundQuad(Camera targetCamera, GameObject videoBGGo)
{
_quad = GameObject.CreatePrimitive(PrimitiveType.Quad);
_quad.name = "FFmpegVideoBGQuad";
// ReSharper disable once Unity.UnresolvedComponentOrScriptableObject
var col = _quad.GetComponent("MeshCollider");
if (col) Destroy(col);
_quad.transform.parent = targetCamera.transform;
var zPos = targetCamera.farClipPlane - 0.5f;
_quad.transform.localPosition = new Vector3(0f, 0f, zPos);
_quad.transform.localRotation = Quaternion.identity;
float height;
float width;
if (targetCamera.orthographic)
{
height = 2.0f * targetCamera.orthographicSize;
width = height * targetCamera.aspect;
}
else
{
height = 2.0f * zPos * Mathf.Tan(targetCamera.fieldOfView * 0.5f * Mathf.Deg2Rad);
width = height * targetCamera.aspect;
}
_quad.transform.localScale = new Vector3(width, height, 1.0f);
_quad.layer = videoBGGo.layer;
// Set vertex colors to white so Sprites/Default works properly (otherwise it may draw transparently)
var meshFilter = _quad.GetComponent<MeshFilter>();
if (meshFilter != null && meshFilter.mesh != null)
{
var mesh = meshFilter.mesh;
var colors = new Color[mesh.vertexCount];
for (var i = 0; i < colors.Length; i++)
{
colors[i] = Color.white;
}
mesh.colors = colors;
}
Shader? shader = null;
if (scrCamera.instance != null)
{
try
{
var camQuadMeshField = typeof(scrCamera).GetField("camQuadMesh", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
if (camQuadMeshField != null)
{
var renderer = camQuadMeshField.GetValue(scrCamera.instance) as MeshRenderer;
if (renderer != null && renderer.sharedMaterial != null)
{
shader = renderer.sharedMaterial.shader;
}
}
}
catch (Exception)
{
// ignored
}
}
if (shader == null) shader = Shader.Find("Sprites/Default");
if (shader == null) shader = Shader.Find("Unlit/Texture");
if (shader == null) shader = Shader.Find("Standard");
_material = new Material(shader)
{
color = Color.white
};
_quad.GetComponent<MeshRenderer>().material = _material;
// Set inactive initially to prevent white flash
_quad.SetActive(false);
}
}