diff --git a/ADOFAIRenderer.csproj b/ADOFAIRenderer.csproj
index 4c7dc3f..358eafc 100644
--- a/ADOFAIRenderer.csproj
+++ b/ADOFAIRenderer.csproj
@@ -8,7 +8,7 @@
false
true
true
- 10
+ 11
diff --git a/API/RenderResult.cs b/API/RenderResult.cs
index 5f6699a..eebcddf 100644
--- a/API/RenderResult.cs
+++ b/API/RenderResult.cs
@@ -1,5 +1,6 @@
using System;
using System.Diagnostics.CodeAnalysis;
+using System.IO;
namespace ADOFAIRenderer.API;
@@ -62,6 +63,25 @@ public class RenderResult
ErrorMessage = errorMessage;
}
+ public 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);
+ }
+
+ public 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.");
+ }
+
public override string ToString()
{
return Success
diff --git a/API/RenderSession.cs b/API/RenderSession.cs
index 44b419a..3319303 100644
--- a/API/RenderSession.cs
+++ b/API/RenderSession.cs
@@ -33,10 +33,13 @@ public class RenderSession
private bool _timePatch = true;
private bool _screenPatch = true;
- private Func _endCondition = () => scrController.instance.state == States.Won;
-
private string? _outputPath;
+ private bool _notification = true;
+
+ private Func? _endCondition;
+ private Action? _onComplete;
+
///
/// Sets whether auto mode is enabled.
///
@@ -89,6 +92,18 @@ public class RenderSession
return this;
}
+ public RenderSession WithNotification(bool enabled)
+ {
+ _notification = enabled;
+ return this;
+ }
+
+ public RenderSession OnComplete(Action callback)
+ {
+ _onComplete += callback;
+ return this;
+ }
+
///
/// Applies the configured flags and initiates the render session.
///
@@ -99,8 +114,11 @@ public class RenderSession
RenderContext.Auto = _auto;
RenderContext.TimePatch = _timePatch;
RenderContext.ScreenPatch = _screenPatch;
- RenderContext.EndCondition = _endCondition;
RenderContext.OutputPath = _outputPath;
+ RenderContext.Notification = _notification;
+
+ if (_endCondition is not null) RenderContext.EndCondition = _endCondition;
+ if (_onComplete is not null) RenderContext.OnComplete = _onComplete;
RenderManager.Instance.levelPath = levelPath;
RenderManager.Instance.Configuration = renderConfig;
diff --git a/Core/RenderManager.cs b/Core/RenderManager.cs
index 38c1f9e..40a0e16 100644
--- a/Core/RenderManager.cs
+++ b/Core/RenderManager.cs
@@ -4,7 +4,9 @@ using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Buffers;
+using System.Diagnostics;
using System.Linq;
+using ADOFAIRenderer.API;
using ADOFAIRenderer.Data;
using ADOFAIRenderer.UI;
using ADOFAIRenderer.Util;
@@ -19,778 +21,715 @@ using UnityEngine.Rendering;
using Random = UnityEngine.Random;
using ThreadPriority = System.Threading.ThreadPriority;
-namespace ADOFAIRenderer.Core
-{
- internal static class RenderContext
- {
- public static bool Auto = true;
- public static bool TimePatch = true;
- public static bool ScreenPatch = true;
- public static Func EndCondition = () => scrController.instance.state == States.Won;
- public static string? OutputPath;
+namespace ADOFAIRenderer.Core;
- public static void Reset()
+internal static class RenderContext
+{
+ public static bool Auto = true;
+ public static bool TimePatch = true;
+ public static bool ScreenPatch = true;
+ public static string? OutputPath;
+
+ public static bool Notification = true;
+
+ private static readonly Func EndConditionDefault = () => scrController.instance.state == States.Won;
+ public static Func EndCondition = EndConditionDefault;
+
+ private static readonly Action OnCompleteDefault = result =>
+ {
+ if (result.Success && Notification)
{
- Auto = true;
- TimePatch = true; ScreenPatch = true;
- EndCondition = () => scrController.instance.state == States.Won;
- OutputPath = null;
+ var fileString = !string.IsNullOrEmpty(RenderManager.Instance.CurrentOutPath) ?
+ $"'{Path.GetFileName(RenderManager.Instance.CurrentOutPath)}'이(가)" : "동영상이";
+
+ var renderFolder = Path.GetDirectoryName(RenderManager.Instance.CurrentOutPath);
+ OSNotification.Show("렌더링 완료!", $"{fileString} 성공적으로 저장되었습니다.", renderFolder);
+ }
+ };
+ public static Action OnComplete = OnCompleteDefault;
+
+ public static void Reset()
+ {
+ Auto = true;
+ TimePatch = true; ScreenPatch = true;
+ OutputPath = null;
+
+ Notification = true;
+
+ EndCondition = EndConditionDefault;
+ OnComplete = OnCompleteDefault;
+ }
+}
+
+internal class RenderManager : MonoBehaviour
+{
+ // Singleton Accessors
+ public static RenderManager Instance { get; private set; } = null!;
+ public static bool IsActive => Instance && Instance.IsRendering;
+ public static bool IsCapturingActive => Instance && Instance.IsCapturing;
+
+ public 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;
+ public long frameCount;
+ public bool skipPressToStart;
+ public string levelPath = "";
+ public bool IsRenderCancelled { get; private set; }
+ 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 double timeBase;
+ public double unscaledTimeBase;
+ public double dspTimeBase;
+ public int frameCountBase;
+
+ public OfflineAudioMixer? AudioMixer { get; private set; }
+ private AudioSyncCalculator SyncCalculator { get; set; } = null!;
+
+ private FrameReader? _frameReader;
+ public bool renderCheck = true;
+ public int skipCount;
+
+ public long frameCountForVideo;
+ public long lastVideoFrameIndex = -1;
+ public long lastRequestedVideoFrameIndex = -1;
+ public int readbackFailureCount;
+ public int throttleCount;
+ public bool isThrottling;
+
+ internal struct CapturedFrame
+ {
+ public IntPtr NativePointer;
+ 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
+ }
+
+ // Thread and Queue State
+ internal SortedList ImageQueue = new();
+ internal AutoResetEvent FrameAvailableEvent = new(false);
+ public Thread? RenderThread;
+ public long passCount;
+ public bool endRequested;
+ public bool endSuccessful;
+ public long wonFrame = -1;
+
+ // Private Instance Fields
+ private FFmpegNativeEncoder? _activeVideoFFmpeg;
+ private readonly List _allVideoFFmpegInstances = new();
+ private int _originalInputOffset;
+ private GameObject? _debugMonitorObj;
+ private GameObject? _escapeHandlerObj;
+ internal string? CurrentOutPath;
+ private bool _renderSetup;
+
+ private void Awake()
+ {
+ Instance = this;
+ }
+
+ private void OnDestroy()
+ {
+ CleanUpActiveRender();
+ }
+
+ public void StartLoadingOverride()
+ {
+ OverrideLoading = true;
+ }
+
+ public void StartRendering()
+ {
+ IsRendering = true;
+ }
+
+ public void StartCapturing()
+ {
+ IsCapturing = true;
+ IsRenderCancelled = false;
+ }
+
+ public void ResetRenderState(bool success)
+ {
+ IsRendering = false;
+ IsCapturing = false;
+ OverrideLoading = false;
+ IsRenderCancelled = !success;
+ }
+
+ public void CleanUpActiveRender()
+ {
+ ResetRenderState(success: false);
+
+ AudioSpectrumGenerator.Clear();
+
+ if (_activeVideoFFmpeg != null)
+ {
+ Melon.Logger.Msg("게임 종료 또는 RenderManager 파괴 감지: FFmpeg 네이티브 인코더 리소스를 해제합니다.");
+ _activeVideoFFmpeg.Dispose();
+ _activeVideoFFmpeg = null;
+ }
+
+ lock (_allVideoFFmpegInstances)
+ {
+ if (_allVideoFFmpegInstances.Count > 0)
+ {
+ Melon.Logger.Msg($"게임 종료 감지: 생성된 모든 FFmpeg 인코더 인스턴스({_allVideoFFmpegInstances.Count}개)를 수거 및 해제합니다.");
+ foreach (var instance in _allVideoFFmpegInstances)
+ {
+ try
+ {
+ instance.Dispose();
+ }
+ catch
+ {
+ // Ignore
+ }
+ }
+ _allVideoFFmpegInstances.Clear();
+ }
+ }
+
+ if (RenderThread is { IsAlive: true })
+ {
+ RenderThread.Join(200);
}
}
-
- internal class RenderManager : MonoBehaviour
+
+ private void Update()
{
- // 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 const int MaxQueuedFrames = 64;
- public const int MaxRenderRetry = 64;
-
- // Statistics
- public long framesSent;
-
- // Core Render Configuration and Path
- public int countdown;
- public long frameCount;
- public bool skipPressToStart;
- public string levelPath = "";
- public bool IsRenderCancelled { get; private set; }
- 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 double timeBase;
- public double unscaledTimeBase;
- public double dspTimeBase;
- public int frameCountBase;
-
- public OfflineAudioMixer? AudioMixer { get; private set; }
- private AudioSyncCalculator SyncCalculator { get; set; } = null!;
-
- private FrameReader? _frameReader;
- public bool renderCheck = true;
- public int skipCount;
-
- public long frameCountForVideo;
- public long lastVideoFrameIndex = -1;
- public long lastRequestedVideoFrameIndex = -1;
- public int readbackFailureCount;
- public int throttleCount;
- public bool isThrottling;
-
- internal struct CapturedFrame
+ if (_renderSetup && ADOBase.lm)
{
- public IntPtr NativePointer;
- 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
+ ADOBase.lm.hideDifficultyUI = true;
+ }
+
+ if (_renderSetup && scnGame.instance && !scnGame.instance.isLoading)
+ {
+ _renderSetup = false;
+ scrCamera.instance.forceRTCam = true;
+
+ if (!scrCamera.instance.gameObject.GetComponent())
+ scrCamera.instance.gameObject.AddComponent();
+
+ _originalInputOffset = scrConductor.currentPreset.inputOffset;
+ scrConductor.currentPreset.inputOffset = 0;
+
+ RDC.auto = RenderContext.Auto;
+ AccessTools.Field(typeof(scrController), "disableCongratsMessage")
+ .SetValue(scrController.instance, true);
+
+ if (scrController.instance)
+ {
+ scrController.instance.noFail = true;
+ scrController.instance.noFailInfiniteMargin = true;
+ }
+
+ StartRendering();
+ countdown = 3;
+ wonFrame = -1;
+
+ if (!_escapeHandlerObj)
+ {
+ _escapeHandlerObj = new GameObject("EscapeHandlerCanvas");
+ _escapeHandlerObj.AddComponent();
+ DontDestroyOnLoad(_escapeHandlerObj);
+ }
+
+ Time.captureDeltaTime = (float)(1 / Configuration.RenderFPS);
+ scrController.instance!.StartCoroutine(SetupPlay());
}
- // Thread and Queue State
- internal SortedList ImageQueue = new();
- internal AutoResetEvent FrameAvailableEvent = new(false);
- public Thread? RenderThread;
- public long passCount;
- public bool endRequested;
- public bool endSuccessful;
- public long wonFrame = -1;
-
- // Private Instance Fields
- private FFmpegNativeEncoder? _activeVideoFFmpeg;
- private readonly List _allVideoFFmpegInstances = new();
- private int _originalInputOffset;
- private GameObject? _debugMonitorObj;
- private GameObject? _escapeHandlerObj;
- private string? _currentOutPath;
- private bool _renderSetup;
-
- private void Awake()
- {
- Instance = this;
- }
-
- private void OnDestroy()
- {
- CleanUpActiveRender();
- }
-
- public void StartLoadingOverride()
- {
- OverrideLoading = true;
- }
-
- public void StartRendering()
- {
- IsRendering = true;
- }
-
- public void StartCapturing()
- {
- IsCapturing = true;
- IsRenderCancelled = false;
- }
-
- public void ResetRenderState(bool success)
- {
- IsRendering = false;
- IsCapturing = false;
- OverrideLoading = false;
- IsRenderCancelled = !success;
- }
-
- public void CleanUpActiveRender()
+ if (!IsRendering) return;
+ if (!scrController.instance || scrController.instance.state == States.Fail || scrController.instance.state == States.Fail2)
{
ResetRenderState(success: false);
-
- AudioSpectrumGenerator.Clear();
-
- if (_activeVideoFFmpeg != null)
- {
- Melon.Logger.Msg("게임 종료 또는 RenderManager 파괴 감지: FFmpeg 네이티브 인코더 리소스를 해제합니다.");
- _activeVideoFFmpeg.Dispose();
- _activeVideoFFmpeg = null;
- }
-
- lock (_allVideoFFmpegInstances)
- {
- if (_allVideoFFmpegInstances.Count > 0)
- {
- Melon.Logger.Msg($"게임 종료 감지: 생성된 모든 FFmpeg 인코더 인스턴스({_allVideoFFmpegInstances.Count}개)를 수거 및 해제합니다.");
- foreach (var instance in _allVideoFFmpegInstances)
- {
- try
- {
- instance.Dispose();
- }
- catch
- {
- // Ignore
- }
- }
- _allVideoFFmpegInstances.Clear();
- }
- }
-
- if (RenderThread is { IsAlive: true })
- {
- RenderThread.Join(200);
- }
+ StopFFmpeg(false);
+ EndRender();
}
-
- private void Update()
+ else if (RenderContext.EndCondition())
{
- if (_renderSetup && ADOBase.lm)
+ if (wonFrame == -1)
{
- ADOBase.lm.hideDifficultyUI = true;
- }
-
- if (_renderSetup && scnGame.instance && !scnGame.instance.isLoading)
- {
- _renderSetup = false;
- scrCamera.instance.forceRTCam = true;
-
- if (!scrCamera.instance.gameObject.GetComponent())
- scrCamera.instance.gameObject.AddComponent();
-
- _originalInputOffset = scrConductor.currentPreset.inputOffset;
- scrConductor.currentPreset.inputOffset = 0;
-
- RDC.auto = RenderContext.Auto;
- AccessTools.Field(typeof(scrController), "disableCongratsMessage")
- .SetValue(scrController.instance, true);
-
- if (scrController.instance)
- {
- scrController.instance.noFail = true;
- scrController.instance.noFailInfiniteMargin = true;
- }
-
- StartRendering();
- countdown = 3;
- wonFrame = -1;
-
- if (!_escapeHandlerObj)
- {
- _escapeHandlerObj = new GameObject("EscapeHandlerCanvas");
- _escapeHandlerObj.AddComponent();
- DontDestroyOnLoad(_escapeHandlerObj);
- }
-
- Time.captureDeltaTime = (float)(1 / Configuration.RenderFPS);
- scrController.instance!.StartCoroutine(SetupPlay());
+ wonFrame = frameCount;
}
- if (!IsRendering) return;
- if (!scrController.instance || scrController.instance.state == States.Fail || scrController.instance.state == States.Fail2)
- {
- ResetRenderState(success: false);
- StopFFmpeg(false);
- EndRender();
- }
- else if (RenderContext.EndCondition())
- {
- if (wonFrame == -1)
- {
- wonFrame = frameCount;
- }
-
- var extraFrames = (long)Math.Ceiling(Configuration.DelayAfterEnd * Configuration.RenderFPS);
+ var extraFrames = (long)Math.Ceiling(Configuration.DelayAfterEnd * Configuration.RenderFPS);
- if (frameCount - wonFrame < extraFrames) return;
- ResetRenderState(success: true);
- StopFFmpeg(true);
- }
+ if (frameCount - wonFrame < extraFrames) return;
+ ResetRenderState(success: true);
+ StopFFmpeg(true);
}
+ }
- public void OnPreRenderUpdate()
+ public void OnPreRenderUpdate()
+ {
+ if (!IsRendering) return;
+
+ // [렉으로 인한 프레임 렌더링 스킵 시 재시도 로직 (원본 모드 이식)]
+ if (renderCheck && IsCapturing)
{
- if (!IsRendering) return;
-
- // [렉으로 인한 프레임 렌더링 스킵 시 재시도 로직 (원본 모드 이식)]
- if (renderCheck && IsCapturing)
+ if (skipCount < MaxRenderRetry)
{
- if (skipCount < MaxRenderRetry)
- {
- Melon.Logger.Warning($"[Render Skipped] Frame {frameCount} rendering missed. Retrying... (Attempt {skipCount + 1}/{MaxRenderRetry})");
- skipCount++;
- Time.timeScale = 0f; // 진행 시간 멈추기 (재시도)
- return; // 업데이트 진행하지 않고 대기
- }
-
- // 60회 초과 시 최후의 블랙 프레임 삽입하여 전체 파이프라인 마비 방지
- Melon.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.Shared.Rent(byteSize);
- Array.Clear(blackFrame, 0, byteSize);
-
- lock (ImageQueue)
- {
- ImageQueue[videoFrameIndex] = new CapturedFrame
- {
- ManagedBuffer = blackFrame,
- Length = byteSize
- };
- }
- FrameAvailableEvent.Set();
- }
-
- renderCheck = false; // 진행 허용
+ Melon.Logger.Warning($"[Render Skipped] Frame {frameCount} rendering missed. Retrying... (Attempt {skipCount + 1}/{MaxRenderRetry})");
+ skipCount++;
+ Time.timeScale = 0f; // 진행 시간 멈추기 (재시도)
+ return; // 업데이트 진행하지 않고 대기
}
- skipCount = 0;
- renderCheck = true; // 다음 프레임 검사용으로 초기화
- frameCount++;
- }
-
- public void OnLastUpdate()
- {
- if (!OverrideLoading) 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;
- }
- }
-
- // ReSharper disable once Unity.IncorrectMethodSignature
- public void OnPostFrame(RenderTexture? texture)
- {
- if (!IsCapturing || !texture) return;
-
- renderCheck = false; // 이번 프레임 정상 렌더링 완료
-
- // [Backpressure Throttling (2번 작업)]
- isThrottling = false;
-
- while (IsCapturing && IsRendering)
- {
- int count;
- lock (ImageQueue)
- {
- count = ImageQueue.Count;
- }
-
- if (count <= MaxQueuedFrames) break;
-
- if (!isThrottling)
- {
- isThrottling = true;
- throttleCount++;
- }
+ // 60회 초과 시 최후의 블랙 프레임 삽입하여 전체 파이프라인 마비 방지
+ Melon.Logger.Error($"[Render Skipped] Frame {frameCount} rendering failed after 60 retries. Supplying black frame.");
- AsyncGPUReadback.WaitAllRequests();
- }
- isThrottling = false;
-
var currentFrame = frameCountForVideo++;
-
var renderFPS = Configuration.RenderFPS;
- var videoFps = Configuration.VideoFPS;
- var effectiveVideoFps = Math.Min(videoFps, renderFPS);
-
+ var videoFPS = Configuration.VideoFPS;
+ var effectiveVideoFps = Math.Min(videoFPS, renderFPS);
var videoFrameIndex = (long)(currentFrame * effectiveVideoFps / renderFPS);
if (videoFrameIndex > lastVideoFrameIndex)
{
lastVideoFrameIndex = videoFrameIndex;
lastRequestedVideoFrameIndex = videoFrameIndex;
- _frameReader?.RequestFrameCapture(texture, videoFrameIndex);
+
+ var byteSize = Configuration.RenderWidth * Configuration.RenderHeight * 4;
+ var blackFrame = ArrayPool.Shared.Rent(byteSize);
+ Array.Clear(blackFrame, 0, byteSize);
+
+ lock (ImageQueue)
+ {
+ ImageQueue[videoFrameIndex] = new CapturedFrame
+ {
+ ManagedBuffer = blackFrame,
+ Length = byteSize
+ };
+ }
+ FrameAvailableEvent.Set();
}
+
+ renderCheck = false; // 진행 허용
}
- public static void StartRender()
- {
- var targetMethod = typeof(scnEditor).GetMethod("TryApplicationQuit",
- System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public);
+ skipCount = 0;
+ renderCheck = true; // 다음 프레임 검사용으로 초기화
+ frameCount++;
+ }
- if (targetMethod == null || !scnEditor.instance) return;
- if (ADOBase.levelPath.IsNullOrEmpty())
- {
- scnEditor.instance.TryQuitToMenu();
- return;
- }
+ public void OnLastUpdate()
+ {
+ if (!OverrideLoading) 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;
+ }
+ }
+
+ // ReSharper disable once Unity.IncorrectMethodSignature
+ public void OnPostFrame(RenderTexture? texture)
+ {
+ if (!IsCapturing || !texture) return;
+
+ renderCheck = false; // 이번 프레임 정상 렌더링 완료
+
+ // [Backpressure Throttling (2번 작업)]
+ isThrottling = false;
- if (Instance) Instance.levelPath = ADOBase.levelPath;
- scnEditor.instance.CheckUnsavedChanges(() =>
+ while (IsCapturing && IsRendering)
+ {
+ int count;
+ lock (ImageQueue)
{
- var quitDelegate =
- (Func)Delegate.CreateDelegate(typeof(Func), scnEditor.instance, targetMethod);
-
- if (Instance) Instance.StartLoadingOverride();
- Application.wantsToQuit -= quitDelegate;
- ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "Assets/Scenes/RenderSettingsScene.unity");
- });
- }
-
- public void SetupCaptureDirectory()
- {
- var renderDir = Path.Combine(MelonEnvironment.GameRootDirectory, "Render");
- FileUtil.CreateDirectory(renderDir);
- }
-
- public void SetupGameScene()
- {
- GCS.customLevelPaths = new[] { levelPath };
- GCS.checkpointNum = 0;
- GCS.customLevelId = "";
- GCS.nextSpeedRun = 1;
- GCS.customLevelIndex = 0;
-
- if (Configuration.AdvancedConfig.RandomSeed.HasValue) Random.InitState(Configuration.AdvancedConfig.RandomSeed.Value);
- ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnGame");
- skipPressToStart = true;
- _renderSetup = true;
- }
-
- private IEnumerator SetupPlay()
- {
- if (Configuration.AudioConfig.RenderAudio && Configuration.VideoConfig.Codec is Codec.VP9 or Codec.AV1 or Codec.VVC)
- {
- Configuration.AudioConfig.AudioSampleRate = 48000;
+ count = ImageQueue.Count;
}
- for (; countdown > 0; countdown--)
+ if (count <= MaxQueuedFrames) break;
+
+ if (!isThrottling)
{
- yield return new WaitForSecondsRealtime(1);
+ isThrottling = true;
+ throttleCount++;
}
+
+ AsyncGPUReadback.WaitAllRequests();
+ }
+ isThrottling = false;
+
+ var currentFrame = frameCountForVideo++;
- frameCount = 0;
- framesSent = 0;
- timeBase = Time.timeAsDouble;
- unscaledTimeBase = Time.unscaledTimeAsDouble;
- dspTimeBase = AudioSettings.dspTime;
- frameCountBase = Time.frameCount;
+ var renderFPS = Configuration.RenderFPS;
+ var videoFps = Configuration.VideoFPS;
+ var effectiveVideoFps = Math.Min(videoFps, renderFPS);
- _frameReader?.Dispose();
- _frameReader = null;
+ var videoFrameIndex = (long)(currentFrame * effectiveVideoFps / renderFPS);
+
+ if (videoFrameIndex > lastVideoFrameIndex)
+ {
+ lastVideoFrameIndex = videoFrameIndex;
+ lastRequestedVideoFrameIndex = videoFrameIndex;
+ _frameReader?.RequestFrameCapture(texture, videoFrameIndex);
+ }
+ }
+
+ public static void StartRender()
+ {
+ var targetMethod = typeof(scnEditor).GetMethod("TryApplicationQuit",
+ System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public);
+
+ if (targetMethod == null || !scnEditor.instance) return;
+ if (ADOBase.levelPath.IsNullOrEmpty())
+ {
+ scnEditor.instance.TryQuitToMenu();
+ return;
+ }
+
+ if (Instance) Instance.levelPath = ADOBase.levelPath;
+ scnEditor.instance.CheckUnsavedChanges(() =>
+ {
+ var quitDelegate =
+ (Func)Delegate.CreateDelegate(typeof(Func), scnEditor.instance, targetMethod);
+
+ if (Instance) Instance.StartLoadingOverride();
+ Application.wantsToQuit -= quitDelegate;
+ ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "Assets/Scenes/RenderSettingsScene.unity");
+ });
+ }
+
+ public void SetupCaptureDirectory()
+ {
+ var renderDir = Path.Combine(MelonEnvironment.GameRootDirectory, "Render");
+ FileUtil.CreateDirectory(renderDir);
+ }
+
+ public void SetupGameScene()
+ {
+ GCS.customLevelPaths = new[] { levelPath };
+ GCS.checkpointNum = 0;
+ GCS.customLevelId = "";
+ GCS.nextSpeedRun = 1;
+ GCS.customLevelIndex = 0;
+
+ if (Configuration.AdvancedConfig.RandomSeed.HasValue) Random.InitState(Configuration.AdvancedConfig.RandomSeed.Value);
+ ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnGame");
+ skipPressToStart = true;
+ _renderSetup = true;
+ }
+
+ private IEnumerator SetupPlay()
+ {
+ if (Configuration.AudioConfig.RenderAudio && Configuration.VideoConfig.Codec is Codec.VP9 or Codec.AV1 or Codec.VVC)
+ {
+ Configuration.AudioConfig.AudioSampleRate = 48000;
+ }
+
+ for (; countdown > 0; countdown--)
+ {
+ yield return new WaitForSecondsRealtime(1);
+ }
+
+ frameCount = 0; framesSent = 0;
+ _renderStopwatch.Restart();
+
+ timeBase = Time.timeAsDouble;
+ unscaledTimeBase = Time.unscaledTimeAsDouble;
+ dspTimeBase = AudioSettings.dspTime;
+ frameCountBase = Time.frameCount;
+
+ _frameReader?.Dispose();
+ _frameReader = null;
+ try
+ {
+ StartFFmpegThread();
+ _frameReader = _activeVideoFFmpeg != null ? new FrameReader(_activeVideoFFmpeg.SelectedPixelFormat) : new FrameReader(AVPixelFormat.AV_PIX_FMT_YUV420P);
+ }
+ catch (Exception ex)
+ {
+ Melon.Logger.Error($"인코더 초기화 에러: {ex}");
+ CommonWarningDialogUI.ShowDialog(
+ "인코더 오류",
+ $"비디오 인코더를 초기화할 수 없습니다.\n오류: {ex.Message}",
+ new DialogButton("확인", () =>
+ {
+ ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnEditor");
+ })
+ );
+ yield break;
+ }
+
+ StartCapturing();
+ scrController.instance.paused = scrController.instance.audioPaused = false;
+ scrController.instance.levelWasSkipped = true;
+
+ if (!scrConductor.instance || !scrConductor.instance.song) yield break;
+ if (Configuration.AdvancedConfig.ProcessAudio)
+ {
+ AudioSpectrumGenerator.Initialize(scrConductor.instance.song.clip);
+ }
+
+ if (Configuration.AudioConfig.RenderAudio)
+ {
+ AudioMixer = new OfflineAudioMixer(Configuration.AudioConfig.AudioChannels, Configuration.AudioConfig.AudioSampleRate);
+ AudioMixer.InitializeBGM(scrConductor.instance.song.clip);
+
+ var effectiveVideoFps = Math.Min(Configuration.VideoFPS, Configuration.RenderFPS);
+ SyncCalculator = new AudioSyncCalculator(Configuration.AudioConfig.AudioSampleRate, effectiveVideoFps);
+ }
+
+ scrConductor.instance.song.clip = null;
+ scrConductor.instance.song.time = -1000;
+ }
+
+ private void StartFFmpegThread()
+ {
+ if (!_debugMonitorObj)
+ {
+ _debugMonitorObj = new GameObject("DebugMonitorCanvas");
+ _debugMonitorObj.AddComponent();
+ DontDestroyOnLoad(_debugMonitorObj);
+ }
+
+ lock (ImageQueue)
+ {
+ ImageQueue.Clear();
+ }
+ passCount = 0;
+ IsRenderCancelled = false;
+ frameCountForVideo = 0;
+ lastVideoFrameIndex = -1;
+ lastRequestedVideoFrameIndex = -1;
+ renderCheck = true;
+ skipCount = 0;
+ endRequested = false;
+ endSuccessful = false;
+ FrameAvailableEvent.Reset();
+
+ var renderFPS = Configuration.RenderFPS;
+ var videoFPS = Configuration.VideoFPS;
+
+ var startTime = DateTime.Now;
+ if (RenderContext.OutputPath != null) CurrentOutPath = RenderContext.OutputPath;
+ else {
+ var fileName = startTime.ToString("yyyy-MM-dd HH-mm-ss") +
+ $".{Configuration.AdvancedConfig.FileExtension.TrimStart('.').ToLower()}";
+ CurrentOutPath = Path.Combine(MelonEnvironment.GameRootDirectory, "Render", fileName);
+ }
+
+ FFmpegNativeEncoder? videoFFmpeg = null;
+ try
+ {
+ var effectiveVideoFps = Math.Min(videoFPS, renderFPS);
+ var codecId = AudioCodecMatch[Configuration.VideoConfig.Codec];
+ var settings = new FFmpegNativeEncoder.EncoderSettings
+ {
+ Width = Configuration.RenderWidth,
+ Height = Configuration.RenderHeight,
+ FPS = effectiveVideoFps,
+ Codec = Configuration.VideoConfig.Codec,
+ Mode = Configuration.VideoConfig.RateControlMode,
+ QualityValue = Configuration.VideoConfig.QP,
+ TargetBitrate = Configuration.VideoConfig.Bitrate,
+ MaxBitrate = Configuration.VideoConfig.MaxBitrate,
+ GopSize = Configuration.VideoConfig.KeyframeInterval,
+ ForceSoftware = Configuration.VideoConfig.ForceSoftwareEncoder,
+ Audio = Configuration.AudioConfig.RenderAudio ? new FFmpegNativeEncoder.AudioSettings
+ {
+ SampleRate = Configuration.AudioConfig.AudioSampleRate,
+ Channels = Configuration.AudioConfig.AudioChannels,
+ InputSampleFormat = AVSampleFormat.AV_SAMPLE_FMT_FLT,
+ AudioCodecId = codecId,
+ Bitrate = Configuration.AudioConfig.AudioBitrate
+ } : null
+ };
+
+ videoFFmpeg = new FFmpegNativeEncoder(settings);
+
+ _activeVideoFFmpeg = videoFFmpeg;
+ lock (_allVideoFFmpegInstances)
+ {
+ _allVideoFFmpegInstances.Add(videoFFmpeg);
+ }
+ videoFFmpeg.Start(CurrentOutPath);
+ }
+ catch (Exception)
+ {
+ if (videoFFmpeg != null)
+ {
+ lock (_allVideoFFmpegInstances)
+ {
+ _allVideoFFmpegInstances.Remove(videoFFmpeg);
+ }
+ videoFFmpeg.Dispose();
+ }
+ _activeVideoFFmpeg = null;
+ throw;
+ }
+
+ RenderThread = new Thread(() =>
+ {
try
{
- StartFFmpegThread();
- _frameReader = _activeVideoFFmpeg != null ? new FrameReader(_activeVideoFFmpeg.SelectedPixelFormat) : new FrameReader(AVPixelFormat.AV_PIX_FMT_YUV420P);
+ while (true)
+ {
+ CapturedFrame frame = default;
+ bool hasFrame = false;
+ lock (ImageQueue)
+ {
+ if (ImageQueue.Count > 0)
+ {
+ var firstKey = ImageQueue.Keys[0];
+
+ if (firstKey > passCount && !IsCapturing)
+ {
+ passCount = firstKey;
+ }
+
+ if (firstKey == passCount)
+ {
+ frame = ImageQueue[firstKey];
+ ImageQueue.RemoveAt(0);
+ passCount++;
+ hasFrame = true;
+ }
+ }
+ }
+
+ if (hasFrame)
+ {
+ if (frame.ManagedBuffer != null)
+ {
+ if (frame.ManagedBuffer.Length > 0)
+ {
+ videoFFmpeg.SendDataFromBuffer(frame.ManagedBuffer, frame.Length);
+ Interlocked.Increment(ref framesSent);
+
+ ArrayPool.Shared.Return(frame.ManagedBuffer);
+ }
+ }
+ else if (frame.NativePointer != IntPtr.Zero && frame.Length > 0)
+ {
+ videoFFmpeg.SendDataFromNativeBuffer(frame.NativePointer, frame.Length);
+ Interlocked.Increment(ref framesSent);
+
+ if (frame.ReadbackState != null)
+ {
+ _frameReader?.RecycleState(frame.ReadbackState);
+ }
+ }
+
+ if (Configuration.AudioConfig.RenderAudio)
+ {
+ var frameIdx = (int)(passCount - 1);
+ var effectiveVideoFps = Math.Min(Configuration.VideoFPS, Configuration.RenderFPS);
+ var frameTime = frameIdx / effectiveVideoFps;
+
+ var channels = Configuration.AudioConfig.AudioChannels;
+ var samplesNeeded = SyncCalculator.GetNextFrameSamples();
+
+ var audioBuffer = ArrayPool.Shared.Rent(samplesNeeded * channels);
+ try
+ {
+ AudioMixer?.GenerateAudioForFrame(audioBuffer, samplesNeeded, frameTime, dspTimeBase);
+ videoFFmpeg.WriteAudioSamples(audioBuffer, samplesNeeded);
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(audioBuffer);
+ }
+ }
+ continue;
+ }
+
+ if (IsRenderCancelled) break;
+
+ if (endRequested)
+ {
+ if (passCount > lastRequestedVideoFrameIndex) break;
+ }
+
+ FrameAvailableEvent.WaitOne(50);
+ }
+
+ if (!IsRenderCancelled) videoFFmpeg.End();
+ videoFFmpeg.Dispose();
+
+ lock (_allVideoFFmpegInstances)
+ {
+ _allVideoFFmpegInstances.Remove(videoFFmpeg);
+ }
+
+ _activeVideoFFmpeg = null;
+ endSuccessful = !IsRenderCancelled;
}
catch (Exception ex)
{
- Melon.Logger.Error($"인코더 초기화 에러: {ex}");
- CommonWarningDialogUI.ShowDialog(
- "인코더 오류",
- $"비디오 인코더를 초기화할 수 없습니다.\n오류: {ex.Message}",
- new DialogButton("확인", () =>
- {
- ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnEditor");
- })
- );
- yield break;
- }
-
- StartCapturing();
- scrController.instance.paused = scrController.instance.audioPaused = false;
- scrController.instance.levelWasSkipped = true;
-
- if (!scrConductor.instance || !scrConductor.instance.song) yield break;
- if (Configuration.AdvancedConfig.ProcessAudio)
- {
- AudioSpectrumGenerator.Initialize(scrConductor.instance.song.clip);
- }
-
- if (Configuration.AudioConfig.RenderAudio)
- {
- AudioMixer = new OfflineAudioMixer(Configuration.AudioConfig.AudioChannels, Configuration.AudioConfig.AudioSampleRate);
- AudioMixer.InitializeBGM(scrConductor.instance.song.clip);
-
- var effectiveVideoFps = Math.Min(Configuration.VideoFPS, Configuration.RenderFPS);
- SyncCalculator = new AudioSyncCalculator(Configuration.AudioConfig.AudioSampleRate, effectiveVideoFps);
- }
-
- scrConductor.instance.song.clip = null;
- scrConductor.instance.song.time = -1000;
- }
-
- private void StartFFmpegThread()
- {
- if (!_debugMonitorObj)
- {
- _debugMonitorObj = new GameObject("DebugMonitorCanvas");
- _debugMonitorObj.AddComponent();
- DontDestroyOnLoad(_debugMonitorObj);
- }
-
- lock (ImageQueue)
- {
- ImageQueue.Clear();
- }
- passCount = 0;
- IsRenderCancelled = false;
- frameCountForVideo = 0;
- lastVideoFrameIndex = -1;
- lastRequestedVideoFrameIndex = -1;
- renderCheck = true;
- skipCount = 0;
- endRequested = false;
- endSuccessful = false;
- FrameAvailableEvent.Reset();
-
- var renderFPS = Configuration.RenderFPS;
- var videoFPS = Configuration.VideoFPS;
-
- var startTime = DateTime.Now;
- if (RenderContext.OutputPath != null) _currentOutPath = RenderContext.OutputPath;
- else {
- var fileName = startTime.ToString("yyyy-MM-dd HH-mm-ss") +
- $".{Configuration.AdvancedConfig.FileExtension.TrimStart('.').ToLower()}";
- _currentOutPath = Path.Combine(MelonEnvironment.GameRootDirectory, "Render", fileName);
- }
-
- FFmpegNativeEncoder? videoFFmpeg = null;
- try
- {
- var effectiveVideoFps = Math.Min(videoFPS, renderFPS);
- var codecId = AudioCodecMatch[Configuration.VideoConfig.Codec];
- var settings = new FFmpegNativeEncoder.EncoderSettings
- {
- Width = Configuration.RenderWidth,
- Height = Configuration.RenderHeight,
- FPS = effectiveVideoFps,
- Codec = Configuration.VideoConfig.Codec,
- Mode = Configuration.VideoConfig.RateControlMode,
- QualityValue = Configuration.VideoConfig.QP,
- TargetBitrate = Configuration.VideoConfig.Bitrate,
- MaxBitrate = Configuration.VideoConfig.MaxBitrate,
- GopSize = Configuration.VideoConfig.KeyframeInterval,
- ForceSoftware = Configuration.VideoConfig.ForceSoftwareEncoder,
- Audio = Configuration.AudioConfig.RenderAudio ? new FFmpegNativeEncoder.AudioSettings
- {
- SampleRate = Configuration.AudioConfig.AudioSampleRate,
- Channels = Configuration.AudioConfig.AudioChannels,
- InputSampleFormat = AVSampleFormat.AV_SAMPLE_FMT_FLT,
- AudioCodecId = codecId,
- Bitrate = Configuration.AudioConfig.AudioBitrate
- } : null
- };
-
- videoFFmpeg = new FFmpegNativeEncoder(settings);
-
- _activeVideoFFmpeg = videoFFmpeg;
+ Melon.Logger.Error($"FFmpeg 렌더링 스레드 오류: {ex}");
+ videoFFmpeg.Dispose();
lock (_allVideoFFmpegInstances)
{
- _allVideoFFmpegInstances.Add(videoFFmpeg);
- }
- videoFFmpeg.Start(_currentOutPath);
- }
- catch (Exception)
- {
- if (videoFFmpeg != null)
- {
- lock (_allVideoFFmpegInstances)
- {
- _allVideoFFmpegInstances.Remove(videoFFmpeg);
- }
- videoFFmpeg.Dispose();
+ _allVideoFFmpegInstances.Remove(videoFFmpeg);
}
_activeVideoFFmpeg = null;
- throw;
}
-
- RenderThread = new Thread(() =>
- {
- try
- {
- while (true)
- {
- CapturedFrame frame = default;
- bool hasFrame = false;
- lock (ImageQueue)
- {
- if (ImageQueue.Count > 0)
- {
- var firstKey = ImageQueue.Keys[0];
-
- if (firstKey > passCount && !IsCapturing)
- {
- passCount = firstKey;
- }
-
- if (firstKey == passCount)
- {
- frame = ImageQueue[firstKey];
- ImageQueue.RemoveAt(0);
- passCount++;
- hasFrame = true;
- }
- }
- }
-
- if (hasFrame)
- {
- if (frame.ManagedBuffer != null)
- {
- if (frame.ManagedBuffer.Length > 0)
- {
- videoFFmpeg.SendDataFromBuffer(frame.ManagedBuffer, frame.Length);
- Interlocked.Increment(ref framesSent);
-
- ArrayPool.Shared.Return(frame.ManagedBuffer);
- }
- }
- else if (frame.NativePointer != IntPtr.Zero && frame.Length > 0)
- {
- videoFFmpeg.SendDataFromNativeBuffer(frame.NativePointer, frame.Length);
- Interlocked.Increment(ref framesSent);
-
- if (frame.ReadbackState != null)
- {
- _frameReader?.RecycleState(frame.ReadbackState);
- }
- }
-
- if (Configuration.AudioConfig.RenderAudio)
- {
- var frameIdx = (int)(passCount - 1);
- var effectiveVideoFps = Math.Min(Configuration.VideoFPS, Configuration.RenderFPS);
- var frameTime = frameIdx / effectiveVideoFps;
-
- var channels = Configuration.AudioConfig.AudioChannels;
- var samplesNeeded = SyncCalculator.GetNextFrameSamples();
-
- var audioBuffer = ArrayPool.Shared.Rent(samplesNeeded * channels);
- try
- {
- AudioMixer?.GenerateAudioForFrame(audioBuffer, samplesNeeded, frameTime, dspTimeBase);
- videoFFmpeg.WriteAudioSamples(audioBuffer, samplesNeeded);
- }
- finally
- {
- ArrayPool.Shared.Return(audioBuffer);
- }
- }
- continue;
- }
-
- if (IsRenderCancelled) break;
-
- if (endRequested)
- {
- if (passCount > lastRequestedVideoFrameIndex) break;
- }
-
- FrameAvailableEvent.WaitOne(50);
- }
-
- if (!IsRenderCancelled) videoFFmpeg.End();
- videoFFmpeg.Dispose();
-
- lock (_allVideoFFmpegInstances)
- {
- _allVideoFFmpegInstances.Remove(videoFFmpeg);
- }
-
- _activeVideoFFmpeg = null;
- endSuccessful = !IsRenderCancelled;
- }
- catch (Exception ex)
- {
- Melon.Logger.Error($"FFmpeg 렌더링 스레드 오류: {ex}");
- videoFFmpeg.Dispose();
- lock (_allVideoFFmpegInstances)
- {
- _allVideoFFmpegInstances.Remove(videoFFmpeg);
- }
- _activeVideoFFmpeg = null;
- }
- })
- {
- IsBackground = true,
- Priority = ThreadPriority.AboveNormal
- };
-
- RenderThread.Start();
- }
-
- public void CancelRender()
+ })
{
- ResetRenderState(success: false);
-
- StopFFmpeg(false);
- EndRender();
- }
+ IsBackground = true,
+ Priority = ThreadPriority.AboveNormal
+ };
- private void StopFFmpeg(bool success)
+ RenderThread.Start();
+ }
+
+ public void CancelRender()
+ {
+ ResetRenderState(success: false);
+
+ StopFFmpeg(false);
+ EndRender();
+ }
+
+ private void StopFFmpeg(bool success)
+ {
+ endRequested = true;
+ if (!success)
{
- endRequested = true;
- if (!success)
- {
- IsRenderCancelled = true;
- }
- FrameAvailableEvent.Set();
- skipPressToStart = false;
- _renderSetup = false;
- if (_escapeHandlerObj)
- {
- Destroy(_escapeHandlerObj);
- _escapeHandlerObj = null;
- }
-
- if (success) StartCoroutine(StopRenderCoroutine());
- else
- {
- Melon.Logger.Error("렌더링이 실패했거나 취소되었습니다. 상태 및 리소스를 안전하게 청소합니다.");
-
- // 1. 렌더링 백그라운드 스레드가 큐에 유입된 잔여 프레임 처리를 마무리하고 종료될 때까지 대기
- if (RenderThread is { IsAlive: true })
- {
- RenderThread.Join(2000);
- }
-
- // 2. 스레드 종료 보장 후, 안전하게 FFmpeg 네이티브 인코더 리소스 해제
- if (_activeVideoFFmpeg != null)
- {
- _activeVideoFFmpeg.Dispose();
- _activeVideoFFmpeg = null;
- }
-
- // 3. 메모리 누수 방지: 큐에 렌트된 임시 프레임 버퍼 풀 반환 및 클리어
- lock (ImageQueue)
- {
- foreach (var kvp in ImageQueue.Values)
- {
- if (kvp.ManagedBuffer is { Length: > 0 })
- {
- ArrayPool.Shared.Return(kvp.ManagedBuffer);
- }
- if (kvp.ReadbackState != null)
- {
- _frameReader?.RecycleState(kvp.ReadbackState);
- }
- }
- ImageQueue.Clear();
- }
-
- _frameReader?.Dispose();
-
- // 디버그 모니터 UI 정리
- if (_debugMonitorObj)
- {
- Destroy(_debugMonitorObj);
- _debugMonitorObj = null;
- }
-
- // 4. 인게임 카메라 및 렌더링용 RTCam 상태 안전 원복
- if (scrCamera.instance)
- {
- scrCamera.instance.forceRTCam = false;
- scrCamera.instance.SetupRTCam(false);
-
- var hook = scrCamera.instance.GetComponent();
- if (hook) Destroy(hook);
- }
-
- // 5. 인게임 실패 방지 플래그 원상 복구
- if (scrController.instance)
- {
- scrController.instance.noFail = false;
- scrController.instance.noFailInfiniteMargin = false;
- }
-
- // 6. 취소된 미완성 임시 동영상 파일 즉각 삭제
- if (!string.IsNullOrEmpty(_currentOutPath) && File.Exists(_currentOutPath))
- {
- try
- {
- File.Delete(_currentOutPath);
- Melon.Logger.Msg($"[File Cleanup] 미완성 동영상 삭제 완료: {_currentOutPath}");
- }
- catch (Exception ex)
- {
- Melon.Logger.Error($"[File Cleanup] 임시 동영상 삭제 실패: {ex.Message}");
- }
- _currentOutPath = null;
- }
- }
+ IsRenderCancelled = true;
}
-
- private IEnumerator StopRenderCoroutine()
+ FrameAvailableEvent.Set();
+ skipPressToStart = false;
+ _renderSetup = false;
+ if (_escapeHandlerObj)
{
- yield return new WaitUntil(() => endSuccessful);
- var fileString = !string.IsNullOrEmpty(_currentOutPath) ? $"'{Path.GetFileName(_currentOutPath)}'이(가)" : "동영상이";
-
- var renderFolder = Path.Combine(MelonEnvironment.GameRootDirectory, "Render");
- OSNotification.Show("렌더링 완료!", $"{fileString} 성공적으로 저장되었습니다.", renderFolder);
-
- EndRender();
+ Destroy(_escapeHandlerObj);
+ _escapeHandlerObj = null;
}
-
- private void EndRender()
+
+ if (success) StartCoroutine(StopRenderCoroutine());
+ else
{
- Time.timeScale = 1f;
- Time.captureDeltaTime = 0f;
-
- if (_debugMonitorObj)
+ Melon.Logger.Error("렌더링이 실패했거나 취소되었습니다. 상태 및 리소스를 안전하게 청소합니다.");
+
+ // 1. 렌더링 백그라운드 스레드가 큐에 유입된 잔여 프레임 처리를 마무리하고 종료될 때까지 대기
+ if (RenderThread is { IsAlive: true })
{
- Destroy(_debugMonitorObj);
- _debugMonitorObj = null;
+ RenderThread.Join(2000);
}
-
- RDC.auto = false;
- scrConductor.currentPreset.inputOffset = _originalInputOffset;
- GCS.customLevelPaths = null;
- GCS.customLevelId = null;
-
+
+ // 2. 스레드 종료 보장 후, 안전하게 FFmpeg 네이티브 인코더 리소스 해제
+ if (_activeVideoFFmpeg != null)
+ {
+ _activeVideoFFmpeg.Dispose();
+ _activeVideoFFmpeg = null;
+ }
+
+ // 3. 메모리 누수 방지: 큐에 렌트된 임시 프레임 버퍼 풀 반환 및 클리어
lock (ImageQueue)
{
foreach (var kvp in ImageQueue.Values)
@@ -806,24 +745,135 @@ namespace ADOFAIRenderer.Core
}
ImageQueue.Clear();
}
-
+
_frameReader?.Dispose();
- AudioMixer?.Reset(); AudioMixer = null;
- SyncCalculator.Reset();
+
+ // 디버그 모니터 UI 정리
+ if (_debugMonitorObj)
+ {
+ Destroy(_debugMonitorObj);
+ _debugMonitorObj = null;
+ }
+
+ // 4. 인게임 카메라 및 렌더링용 RTCam 상태 안전 원복
+ if (scrCamera.instance)
+ {
+ scrCamera.instance.forceRTCam = false;
+ scrCamera.instance.SetupRTCam(false);
+
+ var hook = scrCamera.instance.GetComponent();
+ if (hook) Destroy(hook);
+ }
+
+ // 5. 인게임 실패 방지 플래그 원상 복구
+ if (scrController.instance)
+ {
+ scrController.instance.noFail = false;
+ scrController.instance.noFailInfiniteMargin = false;
+ }
+
+ // 6. 취소된 미완성 임시 동영상 파일 즉각 삭제
+ if (!string.IsNullOrEmpty(CurrentOutPath) && File.Exists(CurrentOutPath))
+ {
+ try
+ {
+ File.Delete(CurrentOutPath);
+ Melon.Logger.Msg($"[File Cleanup] 미완성 동영상 삭제 완료: {CurrentOutPath}");
+ }
+ catch (Exception ex)
+ {
+ Melon.Logger.Error($"[File Cleanup] 임시 동영상 삭제 실패: {ex.Message}");
+ }
+ 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 result = BuildResult(success: true);
+ TriggerOnComplete(result);
- RenderContext.Reset();
+ EndRender();
+ }
+
+ private void EndRender()
+ {
+ Time.timeScale = 1f;
+ Time.captureDeltaTime = 0f;
- ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnEditor");
- Cursor.visible = true;
+ if (_debugMonitorObj)
+ {
+ Destroy(_debugMonitorObj);
+ _debugMonitorObj = null;
+ }
+
+ RDC.auto = false;
+ scrConductor.currentPreset.inputOffset = _originalInputOffset;
+ GCS.customLevelPaths = null;
+ GCS.customLevelId = null;
+
+ lock (ImageQueue)
+ {
+ foreach (var kvp in ImageQueue.Values)
+ {
+ if (kvp.ManagedBuffer is { Length: > 0 })
+ {
+ ArrayPool.Shared.Return(kvp.ManagedBuffer);
+ }
+ if (kvp.ReadbackState != null)
+ {
+ _frameReader?.RecycleState(kvp.ReadbackState);
+ }
+ }
+ ImageQueue.Clear();
}
- private static readonly Dictionary 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
- };
+ _frameReader?.Dispose();
+ AudioMixer?.Reset(); AudioMixer = null;
+ SyncCalculator.Reset();
+
+ RenderContext.Reset();
+
+ ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnEditor");
+ Cursor.visible = true;
}
+
+ private RenderResult BuildResult(bool success, string? errorMessage = null)
+ {
+ _renderStopwatch.Stop();
+ var effectiveFps = Math.Min(Configuration.VideoFPS, Configuration.RenderFPS);
+ var sent = Interlocked.Read(ref framesSent);
+
+ return success
+ ? RenderResult.CreateSuccess(CurrentOutPath ?? string.Empty, sent, effectiveFps, _renderStopwatch.Elapsed)
+ : RenderResult.CreateFailure(errorMessage, sent, effectiveFps, _renderStopwatch.Elapsed);
+ }
+
+ private static void TriggerOnComplete(RenderResult result)
+ {
+ try
+ {
+ RenderContext.OnComplete.Invoke(result);
+ }
+ catch (Exception err)
+ {
+ Melon.Logger.Error($"OnComplete callback caused error: {err}");
+ }
+ }
+
+ private static readonly Dictionary 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
+ };
}
\ No newline at end of file