From 4e67cb9b098dc17beb008850fe77109da57ed1e6fb53e70e17a7bab83c2b9826 Mon Sep 17 00:00:00 2001 From: copecone Date: Wed, 19 Aug 2026 15:08:55 +0900 Subject: [PATCH] Add `RenderSession.OnComplete` --- ADOFAIRenderer.csproj | 2 +- API/RenderResult.cs | 20 + API/RenderSession.cs | 24 +- Core/RenderManager.cs | 1498 +++++++++++++++++++++-------------------- 4 files changed, 816 insertions(+), 728 deletions(-) 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