using System; using System.Collections; using System.Collections.Generic; using System.IO; using System.Threading; using System.Buffers; using ADOFAIRenderer.UI; using ADOFAIRenderer.Util; using ADOFAIRenderer.Util.FFmpeg; using MelonLoader; using MelonLoader.Utils; using UnityEngine; namespace ADOFAIRenderer.Core { public 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; // Core Render Configuration and Path public int countdown; public long frameCount; public bool skipPressToStart; public string levelPath = ""; public bool isRenderCancelled; public RenderSettings Configuration; // Render Sessions State (OOP Instance Fields) public bool isRendering; public bool isCapturing; public double timeBase; public double unscaledTimeBase; public double dspTimeBase; public int frameCountBase; 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; public struct RenderSettings { public double TargetFPS; public double VideoFPS; public int RenderWidth; public int RenderHeight; public int Bitrate; public string Codec; public double EndWaitTime; public bool ShowHitText; public RenderSettings(double targetFPS, double videoFPS, int renderWidth, int renderHeight, int bitrate, string codec, double endWaitTime, bool showHitText) { TargetFPS = targetFPS; VideoFPS = videoFPS; RenderWidth = renderWidth; RenderHeight = renderHeight; Bitrate = bitrate; Codec = codec; EndWaitTime = endWaitTime; ShowHitText = showHitText; } } // Thread and Queue State public SortedList ImageQueue = new SortedList(); public Thread? RenderThread; public long passCount; public bool endRequested; public bool endSuccessful; public long wonFrame = -1; // Private Instance Fields private int _originalInputOffset; private GameObject? _debugMonitorObj; private GameObject? _escapeHandlerObj; private string? _currentOutPath; private bool _renderSetup; private void Awake() { Instance = this; } private void Update() { if (_renderSetup && ADOBase.lm) { ADOBase.lm.hideDifficultyUI = true; } if (_renderSetup && scnGame.instance && !scnGame.instance.isLoading) { _renderSetup = false; scrCamera.instance.forceRTCam = true; scrCamera.instance.SetupRTCam(true); if (!scrCamera.instance.gameObject.GetComponent()) scrCamera.instance.gameObject.AddComponent(); _originalInputOffset = scrConductor.currentPreset.inputOffset; scrConductor.currentPreset.inputOffset = 0; RDC.auto = true; if (scrController.instance) { scrController.instance.noFail = true; scrController.instance.noFailInfiniteMargin = true; } timeBase = Time.timeAsDouble; unscaledTimeBase = Time.unscaledTimeAsDouble; dspTimeBase = AudioSettings.dspTime; frameCountBase = Time.frameCount; isRendering = true; countdown = 3; wonFrame = -1; if (!_escapeHandlerObj) { _escapeHandlerObj = new GameObject("EscapeHandlerCanvas"); _escapeHandlerObj.AddComponent(); DontDestroyOnLoad(_escapeHandlerObj); } Time.captureDeltaTime = (float)(1 / Configuration.TargetFPS); scrController.instance!.StartCoroutine(SetupPlay()); } if (!isRendering) return; if (!scrController.instance || scrController.instance.state == States.Fail || scrController.instance.state == States.Fail2) { isRendering = isCapturing = false; StopFFmpeg(false); EndRender(); } else if (scrController.instance.state == States.Won) { if (wonFrame == -1) { wonFrame = frameCount; } var extraFrames = (long)Math.Ceiling(Configuration.EndWaitTime * Configuration.TargetFPS); if (frameCount - wonFrame < extraFrames) return; isRendering = isCapturing = false; StopFFmpeg(true); } } public void OnPreRenderUpdate() { if (!isRendering) return; // [렉으로 인한 프레임 렌더링 스킵 시 재시도 로직 (원본 모드 이식)] if (renderCheck && isCapturing) { 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 targetFps = Configuration.TargetFPS; var videoFps = Configuration.VideoFPS; var effectiveVideoFps = Math.Min(videoFps, targetFps); var videoFrameIndex = (long)(currentFrame * effectiveVideoFps / targetFps); 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] = blackFrame; } } renderCheck = false; // 진행 허용 } skipCount = 0; renderCheck = true; // 다음 프레임 검사용으로 초기화 frameCount++; } // ReSharper disable once Unity.IncorrectMethodSignature public void OnPostRender(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++; } Thread.Sleep(1); } isThrottling = false; var currentFrame = frameCountForVideo++; var targetFps = Configuration.TargetFPS; var videoFps = Configuration.VideoFPS; var effectiveVideoFps = Math.Min(videoFps, targetFps); var videoFrameIndex = (long)(currentFrame * effectiveVideoFps / targetFps); 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 == null) return; if (ADOBase.levelPath.IsNullOrEmpty()) { scnEditor.instance.TryQuitToMenu(); return; } if (Instance != null) { Instance.levelPath = ADOBase.levelPath; } scnEditor.instance.CheckUnsavedChanges(() => { var quitDelegate = (Func)Delegate.CreateDelegate(typeof(Func), scnEditor.instance, targetMethod); Application.wantsToQuit -= quitDelegate; 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); } } public void SetupGameScene() { GCS.customLevelPaths = new[] { levelPath }; GCS.checkpointNum = 0; GCS.customLevelId = ""; GCS.nextSpeedRun = 1; GCS.customLevelIndex = 0; ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnGame"); skipPressToStart = true; _renderSetup = true; } private IEnumerator SetupPlay() { for (; countdown > 0; countdown--) { yield return new WaitForSecondsRealtime(1); } frameCount = 0; _frameReader?.Dispose(); _frameReader = new FrameReader(); StartFFmpegThread(); isCapturing = true; scrController.instance.paused = scrController.instance.audioPaused = false; scrController.instance.levelWasSkipped = true; scrConductor.instance.song.clip = null; scrConductor.instance.song.time = -1000; } private string FindFFmpegPath() { var isWindows = Application.platform == RuntimePlatform.WindowsPlayer; var ffmpegName = isWindows ? "ffmpeg.exe" : "ffmpeg"; // 1. Mods/ADOFAIRenderer/ffmpeg var path1 = Path.Combine(MelonEnvironment.ModsDirectory, "ADOFAIRenderer", ffmpegName); if (File.Exists(path1)) return path1; // 2. Mods/ffmpeg var path2 = Path.Combine(MelonEnvironment.ModsDirectory, ffmpegName); if (File.Exists(path2)) return path2; // 3. GameRootDirectory/ffmpeg var path3 = Path.Combine(MelonEnvironment.GameRootDirectory, ffmpegName); return File.Exists(path3) ? path3 : ""; } private void StartFFmpegThread() { var ffmpegPath = FindFFmpegPath(); if (string.IsNullOrEmpty(ffmpegPath)) { Melon.Logger.Error("ffmpeg 바이너리를 찾을 수 없습니다. Mods/ADOFAIRenderer/ 에 ffmpeg를 넣어주세요."); return; } if (!_debugMonitorObj) { _debugMonitorObj = new GameObject("DebugMonitorCanvas"); _debugMonitorObj.AddComponent(); DontDestroyOnLoad(_debugMonitorObj); } FFmpegProcess.FFmpegPath = ffmpegPath; lock (ImageQueue) { ImageQueue.Clear(); } passCount = 0; isRenderCancelled = false; frameCountForVideo = 0; lastVideoFrameIndex = -1; lastRequestedVideoFrameIndex = -1; renderCheck = true; skipCount = 0; endRequested = false; endSuccessful = false; var targetFps = Configuration.TargetFPS; var videoFps = Configuration.VideoFPS; float bitrate = Configuration.Bitrate; var codecStr = Configuration.Codec; var selectedCodec = VideoFFmpeg.Codec.H264_Default; if (Enum.TryParse(codecStr, true, out var parsedCodec)) { selectedCodec = parsedCodec; } var ext = ".mp4"; if (selectedCodec == VideoFFmpeg.Codec.AV1_Default || selectedCodec == VideoFFmpeg.Codec.AV1_NVIDIA || selectedCodec == VideoFFmpeg.Codec.AV1_QSV || selectedCodec == VideoFFmpeg.Codec.VP9_Default || selectedCodec == VideoFFmpeg.Codec.VP9_NVIDIA) { ext = ".webm"; } var startTime = DateTime.Now; var fileName = startTime.ToString("yyyy-MM-dd HH-mm-ss") + ext; _currentOutPath = Path.Combine(MelonEnvironment.GameRootDirectory, "Render", fileName); RenderThread = new Thread(() => { try { var effectiveVideoFps = Math.Min(videoFps, targetFps); var videoFFmpeg = new VideoFFmpeg(Configuration.RenderWidth, Configuration.RenderHeight, effectiveVideoFps, videoFps, selectedCodec); SetupCaptureDirectory(); videoFFmpeg.Start(_currentOutPath, bitrate); while (true) { byte[]? buffer = null; lock (ImageQueue) { if (ImageQueue.Count > 0) { var firstKey = ImageQueue.Keys[0]; if (firstKey > passCount && !isCapturing) { passCount = firstKey; } if (firstKey == passCount) { buffer = ImageQueue[firstKey]; ImageQueue.RemoveAt(0); passCount++; } } } if (buffer != null) { if (buffer.Length > 0) { var bufferSize = Configuration.RenderWidth * Configuration.RenderHeight * 4; videoFFmpeg.SendDataFromBuffer(buffer, bufferSize); ArrayPool.Shared.Return(buffer); } continue; } if (isRenderCancelled) { break; } if (endRequested) { if (passCount > lastRequestedVideoFrameIndex) { break; } } Thread.Sleep(1); } videoFFmpeg.Close(); endSuccessful = true; } catch (Exception ex) { Melon.Logger.Error($"FFmpeg 렌더링 스레드 오류: {ex}"); } }); RenderThread.Start(); } public void CancelRender() { isRendering = isCapturing = false; isRenderCancelled = true; StopFFmpeg(false); EndRender(); } private void StopFFmpeg(bool success) { endRequested = true; if (!success) { isRenderCancelled = true; } skipPressToStart = false; _renderSetup = false; if (_escapeHandlerObj) { Destroy(_escapeHandlerObj); _escapeHandlerObj = null; } if (success) { StartCoroutine(StopRenderCoroutine()); } else { Melon.Logger.Error("렌더링이 실패했거나 취소되었습니다. 상태 및 리소스를 안전하게 청소합니다."); // 1. 메모리 누수 방지: 큐에 렌트된 임시 프레임 버퍼 풀 반환 및 클리어 lock (ImageQueue) { foreach (var kvp in ImageQueue) { if (kvp.Value != null && kvp.Value.Length > 0) { ArrayPool.Shared.Return(kvp.Value); } } ImageQueue.Clear(); } _frameReader?.Dispose(); // 디버그 모니터 UI 정리 if (_debugMonitorObj) { Destroy(_debugMonitorObj); _debugMonitorObj = null; } // 2. 렌더링 스레드가 큐 비어 있음을 감지하고 break 하도록 대기 if (RenderThread is { IsAlive: true }) { RenderThread.Join(2000); } // 3. 인게임 카메라 및 렌더링용 RTCam 상태 안전 원복 if (scrCamera.instance) { scrCamera.instance.forceRTCam = false; scrCamera.instance.SetupRTCam(false); var hook = scrCamera.instance.GetComponent(); if (hook) Destroy(hook); } // 4. 인게임 실패 방지 플래그 원상 복구 if (scrController.instance) { scrController.instance.noFail = false; scrController.instance.noFailInfiniteMargin = false; } // 5. 취소된 미완성 임시 동영상 파일 즉각 삭제 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; } } } private IEnumerator StopRenderCoroutine() { yield return new WaitUntil(() => endSuccessful); EndRender(); } private void EndRender() { Time.timeScale = 1f; Time.captureDeltaTime = 0f; if (_debugMonitorObj) { Destroy(_debugMonitorObj); _debugMonitorObj = null; } RDC.auto = false; scrConductor.currentPreset.inputOffset = _originalInputOffset; GCS.customLevelPaths = null; GCS.customLevelId = null; _frameReader?.Dispose(); ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnEditor"); } } }