using System; using System.Collections; 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; using ADOFAIRenderer.Util.Audio; using ADOFAIRenderer.Util.FFmpeg; using FFmpeg.AutoGen; using HarmonyLib; using MelonLoader; using UnityEngine; 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 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) { var fileString = !string.IsNullOrEmpty(RenderManager.Instance.CurrentOutPath) ? $"'{Path.GetFileName(RenderManager.Instance.CurrentOutPath)}'" : "Video"; var renderFolder = Path.GetDirectoryName(RenderManager.Instance.CurrentOutPath); OSNotification.Show("Rendering Complete!", $"{fileString} saved successfully.", renderFolder); } }; public static Action OnComplete = OnCompleteDefault; private static readonly Action OnLastUpdateDefault = () => { }; public static Action OnLastUpdate = OnLastUpdateDefault; public static void Reset() { Auto = true; TimePatch = true; ScreenPatch = true; OutputPath = null; Notification = true; EndCondition = EndConditionDefault; OnComplete = OnCompleteDefault; OnLastUpdate = OnLastUpdateDefault; } } 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; // 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 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 endFrame = -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("Game exit or RenderManager destruction detected: Disposing FFmpeg native encoder resources."); _activeVideoFFmpeg.Dispose(); _activeVideoFFmpeg = null; } lock (_allVideoFFmpegInstances) { if (_allVideoFFmpegInstances.Count > 0) { Melon.Logger.Msg($"Game exit detected: Cleaning up and disposing all created FFmpeg encoder instances ({_allVideoFFmpegInstances.Count})."); foreach (var instance in _allVideoFFmpegInstances) { try { instance.Dispose(); } catch { // ignored } } _allVideoFFmpegInstances.Clear(); } } if (RenderThread is { IsAlive: true }) { RenderThread.Join(200); } } private void Update() { if (_renderSetup && ADOBase.lm) { 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; endFrame = -1; if (!_escapeHandlerObj) { _escapeHandlerObj = new GameObject("EscapeHandlerCanvas"); _escapeHandlerObj.AddComponent(); DontDestroyOnLoad(_escapeHandlerObj); } Time.captureDeltaTime = (float)(1 / Configuration.RenderFPS); scrController.instance!.StartCoroutine(SetupPlay()); } 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 (endFrame == -1) { endFrame = frameCount; } var extraFrames = (long)Math.Ceiling(Configuration.DelayAfterEnd * Configuration.RenderFPS); if (frameCount - endFrame < extraFrames) return; ResetRenderState(success: true); StopFFmpeg(true); } } public void OnPreUpdate() { if (!IsRendering) return; 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; } TriggerOnLastUpdate(); } // ReSharper disable once Unity.IncorrectMethodSignature public void OnPostFrame(RenderTexture? texture) { if (!IsCapturing || !texture) return; // [Backpressure Throttling] isThrottling = false; while (IsCapturing && IsRendering) { int count; lock (ImageQueue) { count = ImageQueue.Count; } if (count <= MaxQueuedFrames) break; if (!isThrottling) { isThrottling = true; throttleCount++; } AsyncGPUReadback.WaitAllRequests(); } isThrottling = false; 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; _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 = GlobalSettings.EffectiveOutputDirectory; 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($"Encoder initialization error: {ex}"); CommonWarningDialogUI.ShowDialog( "Encoder Error", $"Failed to initialize video encoder.\nError: {ex.Message}", new DialogButton("OK", () => { 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; 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(GlobalSettings.EffectiveOutputDirectory, 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 { 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 rendering thread error: {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(); } private void StopFFmpeg(bool 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("Rendering failed or was cancelled. Safely cleaning up state and resources."); // 1. Wait for render background thread to finish processing remaining queued frames and terminate if (RenderThread is { IsAlive: true }) { RenderThread.Join(2000); } // 2. After ensuring thread termination, safely release FFmpeg native encoder resources if (_activeVideoFFmpeg != null) { _activeVideoFFmpeg.Dispose(); _activeVideoFFmpeg = null; } // 3. Memory leak prevention: Return rented temporary frame buffer pool in queue and clear 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(); // Clean up Debug Monitor UI if (_debugMonitorObj) { Destroy(_debugMonitorObj); _debugMonitorObj = null; } // 4. Safely restore in-game camera and RTCam state for rendering if (scrCamera.instance) { scrCamera.instance.forceRTCam = false; scrCamera.instance.SetupRTCam(false); var hook = scrCamera.instance.GetComponent(); if (hook) Destroy(hook); } // 5. Restore in-game no-fail flags if (scrController.instance) { scrController.instance.noFail = false; scrController.instance.noFailInfiniteMargin = false; } // 6. Immediately delete cancelled incomplete temporary video file if (!string.IsNullOrEmpty(CurrentOutPath) && File.Exists(CurrentOutPath)) { try { File.Delete(CurrentOutPath); Melon.Logger.Msg($"[File Cleanup] Deleted incomplete video: {CurrentOutPath}"); } catch (Exception ex) { Melon.Logger.Error($"[File Cleanup] Failed to delete temporary video: {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); 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; 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(); 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 void TriggerOnLastUpdate() { try { RenderContext.OnLastUpdate.Invoke(); } catch (Exception err) { Melon.Logger.Error($"OnLastUpdate 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 }; }