using System; using System.Collections; using System.Collections.Generic; using System.IO; using System.Threading; using ADOFAIRenderer.Util; using MelonLoader; using MelonLoader.Utils; using UnityEngine; namespace ADOFAIRenderer.Component { public class RenderManager : MonoBehaviour { public static int Countdown; public static int FrameCount; public static bool SkipPressToStart; public static string LevelPath = ""; public static RenderManager Instance = null!; private static int _originalInputOffset; private static GameObject? _debugMonitorObj; public static RenderSettings Configuration; 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 RenderSettings(double targetFPS, double videoFPS, int renderWidth, int renderHeight, int bitrate, string codec, double endWaitTime) { TargetFPS = targetFPS; VideoFPS = videoFPS; RenderWidth = renderWidth; RenderHeight = renderHeight; Bitrate = bitrate; Codec = codec; EndWaitTime = endWaitTime; } } public static SortedList ImageQueue = new SortedList(); public static Thread? RenderThread; public static int PassCount; public static bool EndRequested; public static bool EndSuccessful; public static int WonFrame = -1; private static 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; } RenderMod.TimeBase = Time.timeAsDouble; RenderMod.UnscaledTimeBase = Time.unscaledTimeAsDouble; RenderMod.DspTimeBase = AudioSettings.dspTime; RenderMod.FrameCountBase = Time.frameCount; RenderMod.IsRendering = true; Countdown = 3; scrController.instance!.StartCoroutine(SetupPlay()); } if (!RenderMod.IsRendering) return; if (!scrController.instance || scrController.instance.state == States.Fail || scrController.instance.state == States.Fail2) { RenderMod.IsRendering = RenderMod.IsCapturing = false; Time.timeScale = 1f; scrConductor.currentPreset.inputOffset = _originalInputOffset; StopFFmpeg(false); } else if (scrController.instance.state == States.Won) { if (WonFrame == -1) { WonFrame = FrameCount; } var extraFrames = (int)Math.Ceiling(Configuration.EndWaitTime * Configuration.TargetFPS); if (FrameCount - WonFrame < extraFrames) return; RenderMod.IsRendering = RenderMod.IsCapturing = false; Time.timeScale = 1f; scrConductor.currentPreset.inputOffset = _originalInputOffset; StopFFmpeg(true); } } 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; StartFFmpegThread(); RenderMod.IsCapturing = true; scrController.instance.paused = scrController.instance.audioPaused = false; scrController.instance.levelWasSkipped = true; scrConductor.instance.song.clip = null; scrConductor.instance.song.time = -1000; } private static 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; RenderMod.FrameCountForVideo = 0; RenderMod.RenderCheck = true; RenderMod.SkipCount = 0; EndRequested = false; EndSuccessful = false; WonFrame = -1; 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; RenderThread = new Thread(() => { try { var workingFps = (float)(targetFps % videoFps == 0.0 ? videoFps : targetFps); var videoFFmpeg = new VideoFFmpeg(Configuration.RenderWidth, Configuration.RenderHeight, (int)workingFps, (int)videoFps, selectedCodec); var outPath = Path.Combine(MelonEnvironment.GameRootDirectory, "Render", fileName); SetupCaptureDirectory(); videoFFmpeg.Start(outPath, bitrate); int num = targetFps % videoFps != 0.0 ? 1 : (int)(targetFps / videoFps); while (true) { byte[]? buffer = null; lock (ImageQueue) { if (ImageQueue.Count > 0) { int firstKey = ImageQueue.Keys[0]; if (firstKey == PassCount) { buffer = ImageQueue[firstKey]; ImageQueue.RemoveAt(0); PassCount += num; } } } if (buffer != null) { if (buffer.Length > 0) { var bufferSize = Configuration.RenderWidth * Configuration.RenderHeight * 4; videoFFmpeg.SendDataFromBuffer(buffer, bufferSize); System.Buffers.ArrayPool.Shared.Return(buffer); } continue; } if (EndRequested) { bool hasMore; lock (ImageQueue) { hasMore = ImageQueue.Count > 0; } if (!hasMore) break; } Thread.Sleep(1); } videoFFmpeg.Close(); EndSuccessful = true; } catch (Exception ex) { Melon.Logger.Error($"FFmpeg 렌더링 스레드 오류: {ex}"); } }); RenderThread.Start(); } private void StopFFmpeg(bool success) { EndRequested = true; if (success) { StartCoroutine(StopRenderCoroutine()); } else { Melon.Logger.Error("렌더링이 실패했거나 취소되었습니다."); if (_debugMonitorObj) { Destroy(_debugMonitorObj); _debugMonitorObj = null; } if (RenderThread is { IsAlive: true }) { RenderThread.Join(2000); } } } private static IEnumerator StopRenderCoroutine() { yield return new WaitUntil(() => EndSuccessful); if (_debugMonitorObj) { Destroy(_debugMonitorObj); _debugMonitorObj = null; } RDC.auto = false; ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnEditor"); } } }