using System; using System.Collections; using System.Collections.Generic; using System.IO; using System.Threading; using System.Buffers; using System.Diagnostics; using ADOFAIRenderer.API; using ADOFAIRenderer.Core.Data; using ADOFAIRenderer.Data; using ADOFAIRenderer.Patch.Engine; using ADOFAIRenderer.UI; using ADOFAIRenderer.UI.Window; using ADOFAIRenderer.Util; using ADOFAIRenderer.Util.Audio; using ADOFAIRenderer.Util.FFmpeg; using ADOFAIRenderer.Util.OS; using FFmpeg.AutoGen; using HarmonyLib; using MelonLoader; using UnityEngine; using UnityEngine.Rendering; using Random = UnityEngine.Random; using ThreadPriority = System.Threading.ThreadPriority; namespace ADOFAIRenderer.Core; /// /// Progress of the current render (RenderManager.stage). Values are ordered: checks use comparisons such as /// stage >= OnScene. /// /// None: no render. Set at start-up and whenever a render ends, is cancelled or fails (ResetRenderState). /// Load: RenderSession.Start was called; the game scene (scnGame) is being loaded. /// OnScene: the game scene has loaded (RenderManager.Update set up the scene): the frame counter runs, the /// 3-second countdown is shown, input is ignored. Nothing is captured yet. RenderManager.IsActive is true from this stage. /// Render: the encoder and render thread run and frames are captured (set by SetupPlay after the countdown). /// RenderManager.IsCapturingActive is true; the time patches replace Unity's clock with the render clock. /// /// internal enum RenderStage { None, Load, OnScene, Render } /// /// Options of the current render, copied from a by RenderSession.Start and read by /// RenderManager and the Harmony patches. restores the defaults when the render environment is /// restored (RenderManager.RestoreRenderEnvironment). Main thread only. /// internal static class RenderContext { /// Value assigned to RDC.auto (the game's autoplay flag) when the game scene of the render has loaded. public static bool Auto = true; /// /// Enables the time patches (Time.time / unscaledTime / deltaTime / frameCount, AudioSettings.dspTime) while capturing, /// which drive the game by the render clock (frameCount / RenderFPS) instead of real time. /// public static bool TimePatch = true; /// Enables the Screen.width / Screen.height patches (report the render resolution) while the render is active. public static bool ScreenPatch = true; /// Output file path of the render, or null for a time-stamped file in GlobalSettings.EffectiveOutputDirectory. public static string? OutputPath; /// Whether the default OnComplete callback shows an OS notification after a successful render. public static bool Notification = true; /// Frame hooks of the current session, run before the global hooks (see RenderSession.AddFrameHook). public static readonly List> SessionFrameHooks = new(); /// Default end condition: the level is cleared (scrController state Won). private static readonly Func EndConditionDefault = () => scrController.instance.state == States.Won; /// /// Polled by RenderManager.Update every frame from the OnScene stage. When it first returns true, the render /// continues for RenderConfig.DelayAfterEnd seconds of render time and then ends successfully. /// public static Func EndCondition = EndConditionDefault; /// Default completion callback: shows an OS notification for a successful render when Notification is true. 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); } }; /// /// Called once on the main Unity thread when the render finishes (success) or is cancelled or fails. /// A session callback replaces the default one, so no notification is shown when a session sets OnComplete. /// Exceptions are caught and logged (RenderManager.TriggerOnComplete). /// public static Action OnComplete = OnCompleteDefault; private static readonly Action OnLastUpdateDefault = () => { }; /// /// Called on the main Unity thread every frame after all other LateUpdate calls, through PostRenderHook (attached when the /// game scene of the render has loaded) while the stage is Load or later. /// Exceptions are caught and logged (RenderManager.TriggerOnLastUpdate). /// public static Action OnLastUpdate = OnLastUpdateDefault; /// Restores every option to its default and clears the session frame hooks. public static void Reset() { Auto = true; TimePatch = true; ScreenPatch = true; OutputPath = null; Notification = true; SessionFrameHooks.Clear(); EndCondition = EndConditionDefault; OnComplete = OnCompleteDefault; OnLastUpdate = OnLastUpdateDefault; } } /// /// Drives a render from scene setup to the finished video file. Singleton MonoBehaviour on a DontDestroyOnLoad object /// created by RenderMod. /// /// Flow: RenderSession.Start (stage Load) → SetupGameScene loads scnGame → Update sets up the loaded scene /// (stage OnScene, 3-second countdown) → SetupPlay starts the encoder and the render thread (stage Render) → /// every frame OnPreUpdate advances frameCount, OnPostFrame queues the audio commands and requests the GPU capture of /// the frame (FrameReader, AsyncGPUReadback) → readback completions add frames to ImageQueue → the render thread /// (RunRenderThread) encodes them in frame order together with the audio of each frame. /// /// End: success (EndCondition plus DelayAfterEnd) → StopFFmpeg(true) → StopRenderCoroutine waits for the render /// thread to finish the file → EndRender. Cancel (ESC) or failure → StopFFmpeg(false) → EndRender, which deletes the /// unfinished file. Encoder initialization failure → StopFFmpeg(false) → RestoreRenderEnvironment and an error dialog. /// /// Threads: the main Unity thread runs every public method unless noted otherwise. The render thread reads /// ImageQueue (lock on ImageQueue), passCount (render thread only while it runs), endRequested / endSuccessful /// (volatile), the abort state (volatile, written under _abortStateLock) and lastRequestedVideoFrameIndex /// (Volatile.Read/Write). FrameAvailableEvent wakes the render thread, FrameDequeuedEvent wakes the throttle loop. /// internal class RenderManager : MonoBehaviour { /// The single instance, set in Awake. Unity null checks (if (Instance)) are used before access. public static RenderManager Instance { get; private set; } = null!; /// True from the OnScene stage (scene set up, countdown running) until the render ends. public static bool IsActive => Instance && Instance.stage >= RenderStage.OnScene; /// True while frames are captured and encoded (Render stage). The time patches apply only then. public static bool IsCapturingActive => Instance && Instance.stage >= RenderStage.Render; /// /// Maximum number of frames in ImageQueue. The main Unity thread waits (throttles) while the queue holds more frames. /// public const int MaxQueuedFrames = 64; /// /// Upper bound of the readback buffer bytes of the frames in ImageQueue (4 GiB). /// The frame limit becomes min(MaxQueuedFrames, MaxQueuedBytes / bytes per frame) (see ComputeQueueFrameLimit), /// so it only lowers the frame count at resolutions where MaxQueuedFrames frames exceed this size. /// public const long MaxQueuedBytes = 4L * 1024 * 1024 * 1024; /// /// Wait time of the throttle loop (OnPostFrame) for the render thread to take a frame from ImageQueue. /// When it elapses without a dequeue, AsyncGPUReadback.WaitAllRequests() is called once so the readbacks /// still in flight complete (the render thread may be waiting for one of them to fill a gap in ImageQueue). /// private const int ThrottleWaitMilliseconds = 10; /// Frame limit of ImageQueue applied by the last throttle check (MaxQueuedFrames or lower). public int QueueFrameLimit { get; private set; } = MaxQueuedFrames; // Statistics /// /// Number of video frames sent to the encoder in this render. Incremented by the render thread (Interlocked), /// read with Interlocked.Read by the main Unity thread (result, debug monitor). Reset in SetupPlay. /// public long framesSent; /// Wall-clock time of the render from the end of the countdown (SetupPlay) to BuildResult. private readonly Stopwatch _renderStopwatch = new(); // Core Render Configuration and Path /// /// Seconds left in the countdown before capture starts (3 when the scene is set up, decremented once per real-time /// second by SetupPlay). Shown by scrShowIfDebugPatch and read by scrPlayerManagerPatch. /// public int countdown; /// /// Render frame counter: incremented once per Unity frame by OnPreUpdate from the OnScene stage and reset to 0 when /// capture starts (SetupPlay). The render clock is frameCount / RenderFPS seconds (AudioTime, time patches). /// Main thread only. /// public long frameCount; /// /// Set by SetupGameScene; scrPressToStartPatch consumes it (sets it back to false) to skip the press-to-start /// screen once. Cleared by StopFFmpeg. /// public bool skipPressToStart; /// Path of the level file to render (ADOBase.levelPath of the editor, or the path given to RenderSession.Start). public string levelPath = ""; /// Serializes changes of the abort state (_isRenderCancelled, _renderFailed, _failureReason). private readonly object _abortStateLock = new(); /// The render was cancelled (ESC, or a non-successful stop). Written under _abortStateLock, read by any thread. private volatile bool _isRenderCancelled; /// The render failed (MarkFailed). Written under _abortStateLock, read by any thread. private volatile bool _renderFailed; /// Reason given to the first MarkFailed call of this render; null when the render has not failed. private volatile string? _failureReason; /// True when the render was cancelled. Thread-safe read. // ReSharper disable once MemberCanBePrivate.Global public bool IsRenderCancelled => _isRenderCancelled; /// True when the render failed. Thread-safe read. public bool IsRenderFailed => _renderFailed; /// /// True when the render was cancelled or failed (the two are mutually exclusive) and must stop. /// Read by the render thread before each frame and by readback completions. Thread-safe read. /// public bool IsRenderAborted => _isRenderCancelled || _renderFailed; /// Marks the render as cancelled unless it has already failed (a failure keeps its reason). private void MarkCancelled() { lock (_abortStateLock) { if (!_renderFailed) _isRenderCancelled = true; } } /// /// Marks the render as failed with (shown in the result message as "Rendering failed: reason"). /// The first failure reason wins; ignored once the render is already cancelled or failed. Callable from any thread. /// private void MarkFailed(string reason) { lock (_abortStateLock) { if (_isRenderCancelled || _renderFailed) return; _failureReason = reason; _renderFailed = true; } } /// Clears cancelled, failed and the failure reason. Called when a new encoder starts (StartFFmpegThread). private void ClearAbortState() { lock (_abortStateLock) { _isRenderCancelled = false; _renderFailed = false; _failureReason = null; } } /// Error message of the RenderResult of an aborted render (failure reason, or the cancel message). private string BuildAbortMessage() => IsRenderFailed ? $"Rendering failed: {_failureReason ?? "unknown error"}" : "Rendering was cancelled by user."; /// /// Configuration of the current render: a copy made by RenderSession.Start, so values adjusted during the render /// (the audio sample rate in SetupPlay) do not change the caller's object. Read by the main Unity thread, the render /// thread (audio settings) and the patches. Null until the first render starts. /// public RenderConfig Configuration = null!; // Render Sessions State (OOP Instance Fields) /// Current stage of the render. Written on the main Unity thread; read by the render thread in TryDequeueNextFrame. public RenderStage stage = RenderStage.None; /// Time.timeAsDouble when capture started. The time patch returns timeBase + frameCount / RenderFPS. public double timeBase; /// Time.unscaledTimeAsDouble when capture started. The time patch returns unscaledTimeBase + frameCount / RenderFPS. public double unscaledTimeBase; /// /// AudioSettings.dspTime when capture started (seconds). The dspTime patch returns dspTimeBase + frameCount / RenderFPS; /// audio command times are stored relative to it (scheduled dspTime - dspTimeBase). /// public double dspTimeBase; /// Time.frameCount when capture started. The frameCount patch returns frameCountBase + frameCount. public int frameCountBase; /// /// Offline mixer of the song and sound effects of this render; null when audio is not rendered or no render runs. /// Created in SetupPlay. Commands are added on the main Unity thread (OnPostFrame, audio patches), mixing runs on the /// render thread (WriteAudioForFrame); the mixer synchronizes the two internally. /// public OfflineAudioMixer? AudioMixer { get; private set; } /// Render time of the current frame in seconds (frameCount / RenderFPS), the time base of audio commands. public double AudioTime => frameCount / Configuration.RenderFPS; /// /// Number of audio samples (per channel) to write after each video frame, so that the audio stays aligned with /// the video frame times at the effective video frame rate. Created in SetupPlay when audio is rendered; /// used by the render thread only. /// private AudioSyncCalculator? SyncCalculator { get; set; } /// GPU capture of frames (color conversion and AsyncGPUReadback). Created in SetupPlay after the encoder started. private FrameReader? _frameReader; /// Frame hook runner of this render. Created in SetupPlay, disposed by ResetFrameHookState. private FrameHookPipeline? _frameHookPipeline; /// Session hooks followed by global hooks, rebuilt when the global hook version changes. private readonly List> _activeHooksCache = new(); /// RenderSession.GlobalHooksVersion the cache was built for; -1 forces a rebuild. private int _cachedGlobalHooksVersion = -1; /// True when _activeHooksCache is not empty. private bool _hasActiveHooksCached; /// True when at least one session or global frame hook is registered. Main thread. public bool HasActiveFrameHooks { get { EnsureActiveHooksUpToDate(); return _hasActiveHooksCached; } } /// /// Rebuilds the active hook list when the global hook registrations changed since the last build. /// Session hooks are only read on a rebuild; SetupPlay forces one (version -1) at the start of capture, so changes to /// RenderContext.SessionFrameHooks after that are picked up only together with a global hook change. /// private void EnsureActiveHooksUpToDate() { var globalVersion = RenderSession.GlobalHooksVersion; if (_cachedGlobalHooksVersion == globalVersion) return; _cachedGlobalHooksVersion = globalVersion; _activeHooksCache.Clear(); if (RenderContext.SessionFrameHooks.Count > 0) { _activeHooksCache.AddRange(RenderContext.SessionFrameHooks); } RenderSession.CopyGlobalFrameHooksTo(_activeHooksCache); _hasActiveHooksCached = _activeHooksCache.Count > 0; } /// Returns the cached active hook list (session hooks first, then global hooks). The list is reused. private List> GetActiveFrameHooks() { EnsureActiveHooksUpToDate(); return _activeHooksCache; } /// Disposes the frame hook pipeline (ping-pong buffers) and clears the hook cache. private void ResetFrameHookState() { _frameHookPipeline?.Dispose(); _frameHookPipeline = null; _activeHooksCache.Clear(); _cachedGlobalHooksVersion = -1; _hasActiveHooksCached = false; } /// /// Number of game frames offered for capture since capture started (OnPostFrame with a texture, after throttling). /// The value before the increment is the render frame index of the current frame. Main thread only. /// public long frameCountForVideo; /// /// Video frame index of the last captured frame (-1 before the first). A game frame is captured only when its video /// frame index (render frame index × effective video FPS / RenderFPS, rounded down) is greater. Main thread only. /// public long lastVideoFrameIndex = -1; /// /// Video frame index of the last capture request (-1 before the first). Written on the main Unity thread, read by the /// render thread to know when every requested frame has been sent after endRequested. /// // long cannot be volatile: access via Volatile.Read/Write (written on main Unity thread, read on render thread) public long lastRequestedVideoFrameIndex = -1; /// /// Number of GPU readbacks that failed and were replaced by a black frame (shown by the debug monitor). /// Incremented in the readback completion callback. /// public int readbackFailureCount; /// Number of frames on which the main Unity thread had to wait for the render thread (throttle loop entries). public int throttleCount; /// True while the main Unity thread is inside the throttle wait of OnPostFrame. public bool isThrottling; /// /// One frame waiting in ImageQueue: either a native readback buffer (owned by ReadbackState until it is recycled) /// or a managed buffer rented from ArrayPool<byte>.Shared (black frame for a failed readback). /// The data is YUV 4:2:0 in the encoder's pixel format when IsColorConverted, RGBA otherwise. /// internal struct CapturedFrame { /// Start of the native frame data (ReadbackState.NativeBuffer); zero for a managed frame. public IntPtr NativePointer; /// Number of valid bytes of the frame data. public int Length; /// Managed frame data rented from ArrayPool<byte>.Shared (may be longer than Length); null for a native frame. public byte[]? ManagedBuffer; // Non-null if it's a managed buffer (e.g. black frame) /// Readback state owning NativePointer; returned to the FrameReader pool after the frame is encoded or dropped. public ReadbackRequestState? ReadbackState; // Associated state to recycle /// True when the GPU converted the frame to the encoder's YUV format; false for RGBA data. public bool IsColorConverted; } // Thread and Queue State /// /// Captured frames waiting for the render thread, keyed by video frame index. Readbacks may complete in any order; /// the render thread takes them in index order (passCount). Every access locks the list itself. /// Frames are added by readback completions (main Unity thread) and removed by the render thread, or released by the /// cleanup in StopFFmpeg / RestoreRenderEnvironment. /// internal SortedList ImageQueue = new(); /// Set when a frame is added to ImageQueue or the end is requested; the render thread waits on it. internal AutoResetEvent FrameAvailableEvent = new(false); /// Set by the render thread each time it takes a frame from ImageQueue; the throttle loop waits on it. internal AutoResetEvent FrameDequeuedEvent = new(false); /// The render thread of the current render (background, AboveNormal priority); null before the first render. public Thread? RenderThread; /// /// Video frame index the render thread sends next. Reset by StartFFmpegThread before the thread starts; afterwards /// read and written only by the render thread, under the ImageQueue lock. /// public long passCount; /// Set by StopFFmpeg: no more frames will be captured; the render thread drains the queue and stops. public volatile bool endRequested; /// Set by the render thread after the output file was finished and the encoder released without an abort. public volatile bool endSuccessful; /// /// frameCount at which EndCondition first returned true; -1 until then. The render ends DelayAfterEnd seconds of /// render time later. /// public long endFrame = -1; // Private Instance Fields /// Encoder of the current render; null when none runs. Cleared when the render thread releases it. private FFmpegNativeEncoder? _activeVideoFFmpeg; /// /// Every encoder created and not yet released, disposed by CleanUpActiveRender on exit. Locked on the list itself /// (the render thread removes its encoder when it finishes). /// private readonly List _allVideoFFmpegInstances = new(); /// The user's input offset (scrConductor.currentPreset.inputOffset), set to 0 during the render and restored afterwards. private int _originalInputOffset; /// Debug monitor overlay object, created when the encoder starts and destroyed when the render ends. private GameObject? _debugMonitorObj; /// ESC handler overlay object (cancels the render), created when the scene is set up and destroyed by StopFFmpeg. private GameObject? _escapeHandlerObj; /// /// Output file path of the current or last render. Set when the encoder starts; set to null when an aborted render's /// file was deleted. /// internal string? CurrentOutPath; // Output file of the current render that is not finished yet. // Set just before the encoder opens the file, cleared by the render thread after the encoder finished the file (End). // Only this file is deleted when a render is cancelled, fails or the game exits (DeleteIncompleteOutputFile). private string? _incompleteOutputPath; /// /// True from SetupGameScene until Update has set up the loaded game scene (or the render stops). While true, Update /// hides the difficulty UI and waits for scnGame to finish loading. /// private bool _renderSetup; private void Awake() { Instance = this; } private void OnDestroy() { CleanUpActiveRender(); } /// /// Sets the stage to None. true clears the cancelled flag; false marks the render as /// cancelled (unless it already failed). Called at every end path before StopFFmpeg. /// public void ResetRenderState(bool success) { if (success) _isRenderCancelled = false; else MarkCancelled(); stage = RenderStage.None; } /// /// Emergency cleanup when the game quits or this object is destroyed: marks a running render as failed, releases /// the audio, the frame hooks and every encoder, waits up to 200 ms for the render thread, and deletes the /// unfinished output file. Does not restore the game scene or settings. Main thread. /// public void CleanUpActiveRender() { if (stage != RenderStage.None) MarkFailed("the game exited or RenderManager was destroyed during rendering."); ResetRenderState(success: false); AudioSpectrumGenerator.Clear(); // Cancels the render thread's audio waits, then releases the clip store (decoder thread, readers, chunks). AudioMixer?.Dispose(); AudioMixer = null; ResetFrameHookState(); 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); } // The encoders are disposed above, which closes the output file. An unfinished file of this render is deleted. DeleteIncompleteOutputFile(); } /// /// Deletes the output file of the current render when the render did not finish it (_incompleteOutputPath). /// Files of earlier, finished renders are never deleted. /// Returns true when a file was deleted. /// private bool DeleteIncompleteOutputFile() { var path = Interlocked.Exchange(ref _incompleteOutputPath, null); if (string.IsNullOrEmpty(path) || !File.Exists(path)) return false; try { File.Delete(path); Melon.Logger.Msg($"[File Cleanup] Deleted incomplete video: {path}"); return true; } catch (Exception ex) { Melon.Logger.Error($"[File Cleanup] Failed to delete incomplete video: {ex.Message}"); return false; } } /// /// Main thread, every frame. /// 1. While the game scene of a render loads: hides the difficulty UI of the press-to-start screen. /// 2. Once scnGame has finished loading: sets up the scene for rendering and starts SetupPlay (stage OnScene). /// 3. From the OnScene stage: fails the render when the render thread died, the level failed or the controller is /// gone; ends it successfully DelayAfterEnd seconds (render time) after EndCondition first returned true. /// private void Update() { // scrPressToStart reads lm.hideDifficultyUI to decide whether the difficulty UI is shown (scrPressToStart.cs). if (_renderSetup && ADOBase.lm) { ADOBase.lm.hideDifficultyUI = true; } if (_renderSetup && scnGame.instance && !scnGame.instance.isLoading) { _renderSetup = false; // scrCamera.Awake calls SetupRTCam(enable: true) when forceRTCam is set (scrCamera.cs), so the flag applies to // the next scrCamera.Awake. scnGame also calls SetupRTCam(enable: true) when it starts playing (scnGame.cs). scrCamera.instance.forceRTCam = true; if (!scrCamera.instance.gameObject.GetComponent()) scrCamera.instance.gameObject.AddComponent(); // The input offset shifts the song position (calibration_i = inputOffset / 1000 in scrConductor); // the render runs with 0 so audio and visuals follow the level timing exactly. Restored by RestoreRenderEnvironment. _originalInputOffset = scrConductor.currentPreset.inputOffset; scrConductor.currentPreset.inputOffset = 0; // Autoplay; disableCongratsMessage clears the "congratulations" text shown on the win screen (scrController.cs). RDC.auto = RenderContext.Auto; AccessTools.Field(typeof(scrController), "disableCongratsMessage") .SetValue(scrController.instance, true); // No-fail with an infinite hit margin, so a missed hit does not fail the level during the render. if (scrController.instance) { scrController.instance.noFail = true; scrController.instance.noFailInfiniteMargin = true; } stage = RenderStage.OnScene; countdown = 3; endFrame = -1; if (!_escapeHandlerObj) { _escapeHandlerObj = new GameObject("EscapeHandlerCanvas"); _escapeHandlerObj.AddComponent(); DontDestroyOnLoad(_escapeHandlerObj); } // While non-zero, Unity advances Time.time by exactly this interval per frame regardless of real time, // so the game simulation steps 1 / RenderFPS seconds per frame. Reset to 0 by RestoreRenderEnvironment. // Reference: https://docs.unity3d.com/ScriptReference/Time-captureDeltaTime.html Time.captureDeltaTime = (float)(1 / Configuration.RenderFPS); scrController.instance!.StartCoroutine(SetupPlay()); } if (Instance.stage < RenderStage.OnScene) return; if (stage >= RenderStage.Render && RenderThread is not { IsAlive: true }) { MarkFailed("the render thread terminated unexpectedly."); CancelRender(); return; } if (!scrController.instance || scrController.instance.state == States.Fail || scrController.instance.state == States.Fail2) { MarkFailed("the level failed or the game controller was lost during rendering."); ResetRenderState(success: false); StopFFmpeg(false); EndRender(); } else if (RenderContext.EndCondition()) { // The first frame the end condition holds starts the DelayAfterEnd period; afterwards the end condition is // still polled every frame, but endFrame is kept. if (endFrame == -1) { endFrame = frameCount; } var extraFrames = (long)Math.Ceiling(Configuration.DelayAfterEnd * Configuration.RenderFPS); if (frameCount - endFrame < extraFrames) return; ResetRenderState(success: true); StopFFmpeg(true); } } /// /// Called by FirstUpdateHook before every other Update of the frame. Advances frameCount by one from the OnScene stage. /// public void OnPreUpdate() { if (Instance.stage < RenderStage.OnScene) return; frameCount++; } // Floors that own a hold renderer, collected from scrLevelMaker.listFloors (see RefreshHoldFloors). private readonly List _holdFloors = new(); private List? _holdFloorsSource; private int _holdFloorsSourceCount = -1; /// /// Called by PostRenderHook after every other LateUpdate of the frame, from the Load stage. /// Hides the hold meshes when AdvancedConfig.NonTile is set, then runs RenderContext.OnLastUpdate. /// public void OnLastUpdate() { if (Instance.stage < RenderStage.Load) return; if (Configuration.AdvancedConfig.NonTile) { // scrHoldRenderer.UpdateColor sets m_meshRenderer.enabled = true every time it runs // (scrHoldRenderer.cs, called from scrPlanet/scrPlayer), so the hold meshes are disabled again every frame. RefreshHoldFloors(); foreach (var floor in _holdFloors) { if (!floor || !floor.holdRenderer) continue; var meshRenderer = floor.holdRenderer.m_meshRenderer; if (meshRenderer && meshRenderer.enabled) meshRenderer.enabled = false; } } TriggerOnLastUpdate(); } /// /// Rebuilds the hold floor list when scrLevelMaker.listFloors is a different list or has a different count /// (hold renderers are created together with the floors in scrLevelMaker). /// private void RefreshHoldFloors() { var floors = scrLevelMaker.instance.listFloors; if (ReferenceEquals(floors, _holdFloorsSource) && floors.Count == _holdFloorsSourceCount) return; _holdFloors.Clear(); foreach (var floor in floors) { if (floor && floor.holdRenderer) _holdFloors.Add(floor); } _holdFloorsSource = floors; _holdFloorsSourceCount = floors.Count; } /// /// Frame limit of ImageQueue for the given capture texture: MaxQueuedBytes divided by the readback bytes of /// one frame (YUV 4:2:0 when the GPU conversion is active, RGBA otherwise), within 4 to MaxQueuedFrames. /// private int ComputeQueueFrameLimit(RenderTexture texture) { var frameBytes = _frameReader?.GetFrameBufferBytes(texture.width, texture.height) ?? (long)texture.width * texture.height * 4; if (frameBytes <= 0) return MaxQueuedFrames; return (int)Math.Min(MaxQueuedFrames, Math.Max(4, MaxQueuedBytes / frameBytes)); } /// /// Called by PostRenderHook at the end of every frame (after rendering) with the frame texture. In the Render stage: /// 1. Audio: updates the song voice, commits the audio commands of this frame, and deactivates the effect /// AudioSources that were mapped to mixer voices. /// 2. Throttle: blocks while ImageQueue holds more than QueueFrameLimit frames (render thread behind). /// 3. Capture: when this game frame starts a new video frame, runs the frame hooks and requests the GPU readback. /// With RenderFPS above the effective video FPS, only some game frames are captured. /// /// Frame shown by the camera quad; null skips throttling and capture. // ReSharper disable once Unity.IncorrectMethodSignature public void OnPostFrame(RenderTexture? texture) { if (Instance.stage < RenderStage.Render) return; // Audio commands of this frame are queued: update the song voice and let the render thread mix up to the next frame. if (AudioMixer != null) { AudioMixer.UpdateBGM(AudioTime, dspTimeBase); AudioMixer.Commit((frameCount + 1) / Configuration.RenderFPS); EffectSource.DeactivateMappedSources(AudioMixer); } if (!texture) return; // [Backpressure Throttling] // The main Unity thread waits while ImageQueue holds more than QueueFrameLimit frames. Only the render thread // removes frames, and it signals FrameDequeuedEvent for each one. The wait has a timeout so that the loop // re-checks the render thread state and pumps pending readback completions (see ThrottleWaitMilliseconds). isThrottling = false; var frameLimit = QueueFrameLimit = ComputeQueueFrameLimit(texture!); while (Instance.stage >= RenderStage.Render) { if (RenderThread is not { IsAlive: true }) { isThrottling = false; return; } int count; lock (ImageQueue) { count = ImageQueue.Count; } if (count <= frameLimit) break; if (!isThrottling) { isThrottling = true; throttleCount++; } if (!FrameDequeuedEvent.WaitOne(ThrottleWaitMilliseconds)) { AsyncGPUReadback.WaitAllRequests(); } } isThrottling = false; var currentFrame = frameCountForVideo++; // The video cannot have more frames per second than the game renders: effective video FPS = min(VideoFPS, RenderFPS). var renderFPS = Configuration.RenderFPS; var videoFps = Configuration.VideoFPS; var effectiveVideoFps = Math.Min(videoFps, renderFPS); // Video frame that contains this game frame's time (currentFrame / renderFPS seconds), rounded down. // Only the first game frame of each video frame is captured. var videoFrameIndex = (long)(currentFrame * effectiveVideoFps / renderFPS); if (videoFrameIndex > lastVideoFrameIndex) { lastVideoFrameIndex = videoFrameIndex; Volatile.Write(ref lastRequestedVideoFrameIndex, videoFrameIndex); var targetTexture = texture; if (HasActiveFrameHooks && _frameHookPipeline != null) { var videoTime = effectiveVideoFps > 0 ? videoFrameIndex / effectiveVideoFps : 0.0; targetTexture = _frameHookPipeline.Process( texture, videoFrameIndex, currentFrame, videoTime, renderFPS, effectiveVideoFps, GetActiveFrameHooks() ) ?? texture; } _frameReader?.RequestFrameCapture(targetTexture, videoFrameIndex); } } /// /// Handler of the render button in the level editor (added by scnEditorPatch). /// When the level has no file path, scnEditor.TryQuitToMenu is called and no render starts. /// Otherwise, after the editor's unsaved-changes prompt, removes scnEditor.TryApplicationQuit from /// Application.wantsToQuit (it would block quitting with an unsaved-changes prompt; scnEditor registers it again /// when the editor starts) and opens the render configuration window. Main thread. /// 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); Application.wantsToQuit -= quitDelegate; RenderConfigWindow.CreateWindow(); }); } /// Creates the default output directory (GlobalSettings.EffectiveOutputDirectory) when it does not exist. public void SetupCaptureDirectory() { var renderDir = GlobalSettings.EffectiveOutputDirectory; FileUtil.CreateDirectory(renderDir); } /// /// Loads scnGame with levelPath as the only custom level (checkpoint 0, speed trial multiplier 1) and arms the /// press-to-start skip and the scene setup in Update. Seeds UnityEngine.Random when AdvancedConfig.RandomSeed is set, /// so random effects repeat between renders. Called by RenderSession.Start. /// 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; } /// /// Coroutine started on scrController when the game scene is set up (stage OnScene). /// Waits the countdown (real-time seconds), sets the time bases, starts the encoder, the render thread and the /// frame reader, switches to the Render stage, starts the level without the press-to-start wait, and creates the /// offline audio mixer and spectrum generator. Stops without starting when the render was aborted during the /// countdown. On an encoder initialization error, the render fails, the environment is restored and an error /// dialog returns to the editor. /// private IEnumerator SetupPlay() { // The audio sample rate must be one the audio codec paired with the video codec supports // (CodecUtil.AudioCodecMatch, CodecUtil.AvailableSampleRate); otherwise 48000 Hz is used. if (Configuration.AudioConfig.RenderAudio) { if (!CodecUtil.AvailableSampleRate[CodecUtil.AudioCodecMatch[Configuration.VideoConfig.Codec]].Contains(Configuration.AudioConfig.AudioSampleRate)) Configuration.AudioConfig.AudioSampleRate = 48000; // most compatible value } for (; countdown > 0; countdown--) { yield return new WaitForSecondsRealtime(1); } // Cancelled or failed during the countdown: do not start the encoder if (stage != RenderStage.OnScene) yield break; frameCount = 0; framesSent = 0; _renderStopwatch.Restart(); // Clock values at the start of capture. The time patches are not active yet (stage OnScene), so these are // Unity's own values; from the Render stage the patched clocks continue from them at frameCount / RenderFPS. timeBase = Time.timeAsDouble; unscaledTimeBase = Time.unscaledTimeAsDouble; dspTimeBase = AudioSettings.dspTime; frameCountBase = Time.frameCount; _frameReader?.Dispose(); _frameReader = null; _frameHookPipeline?.Dispose(); _frameHookPipeline = new FrameHookPipeline(); _cachedGlobalHooksVersion = -1; EnsureActiveHooksUpToDate(); // The frame reader converts on the GPU to the pixel format of the encoder that was actually opened. 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}"); // Release the encoder/thread and restore time, input offset, stage and hooks before showing the dialog MarkFailed($"failed to initialize the video encoder: {ex.Message}"); ResetRenderState(success: false); StopFFmpeg(false); RestoreRenderEnvironment(); CommonWarningDialogUI.ShowDialog( "Encoder Error", $"Failed to initialize video encoder.\nError: {ex.Message}", new DialogButton("OK", () => { ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnEditor"); Cursor.visible = true; }) ); yield break; } Instance.stage = RenderStage.Render; // levelWasSkipped ends the press-to-start wait loop of scrController (scrController.cs, WaitForStartCo). scrController.instance.paused = scrController.instance.audioPaused = false; scrController.instance.levelWasSkipped = true; if (!scrConductor.instance || !scrConductor.instance.song) yield break; 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); } if (Configuration.AdvancedConfig.ProcessAudio) { AudioSpectrumGenerator.Initialize(scrConductor.instance.song.clip, AudioMixer); } // The song is played by the offline mixer only: the clip is removed from the game's song AudioSource so Unity // does not play it. scrConductor.instance.song.clip = null; scrConductor.instance.song.time = -1000; } /// /// Resets the queue and render thread state, opens the encoder on the output file (RenderContext.OutputPath, or /// "yyyy-MM-dd HH-mm-ss.ext" in the output directory with the configured extension), and starts the render thread. /// The output path is registered as incomplete before the encoder opens the file. /// /// The encoder could not be created or started; it is disposed before the exception is rethrown. private void StartFFmpegThread() { if (!_debugMonitorObj) { _debugMonitorObj = new GameObject("DebugMonitorCanvas"); _debugMonitorObj.AddComponent(); DontDestroyOnLoad(_debugMonitorObj); } lock (ImageQueue) { ImageQueue.Clear(); } passCount = 0; ClearAbortState(); frameCountForVideo = 0; lastVideoFrameIndex = -1; Volatile.Write(ref lastRequestedVideoFrameIndex, -1); endRequested = false; endSuccessful = false; FrameAvailableEvent.Reset(); FrameDequeuedEvent.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 = CodecUtil.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); } Volatile.Write(ref _incompleteOutputPath, CurrentOutPath); videoFFmpeg.Start(CurrentOutPath); } catch (Exception) { if (videoFFmpeg != null) { lock (_allVideoFFmpegInstances) { _allVideoFFmpegInstances.Remove(videoFFmpeg); } videoFFmpeg.Dispose(); } _activeVideoFFmpeg = null; throw; } // Background thread: it does not keep the process alive when the game quits. var encoder = videoFFmpeg; RenderThread = new Thread(() => RunRenderThread(encoder)) { IsBackground = true, Priority = ThreadPriority.AboveNormal }; RenderThread.Start(); } /// /// Body of the render thread. Sends queued frames and their audio to the encoder until the render ends, /// finishes the output file, and releases the encoder. An exception is handled as a render failure. /// private void RunRenderThread(FFmpegNativeEncoder videoFFmpeg) { try { EncodeQueuedFrames(videoFFmpeg); if (!IsRenderAborted) { // End throws when the file cannot be finished (e.g. the trailer cannot be written), // which is handled as a render failure by the catch block below. videoFFmpeg.End(); Volatile.Write(ref _incompleteOutputPath, null); } videoFFmpeg.Dispose(); UnregisterEncoder(videoFFmpeg); endSuccessful = !IsRenderAborted; } catch (OperationCanceledException) when (IsRenderAborted) { // Audio waits end with OperationCanceledException when the render is cancelled or has failed (OfflineAudioMixer.Cancel). Melon.Logger.Msg("Render thread stopped: the render was aborted."); DisposeEncoderAfterStop(videoFFmpeg); } catch (Exception ex) { Melon.Logger.Error($"FFmpeg rendering thread error: {ex}"); MarkFailed("encoder thread error. See the log for details."); DisposeEncoderAfterStop(videoFFmpeg); } } /// Releases the encoder after the render thread stopped early (abort or error) and removes it from the encoder list. private void DisposeEncoderAfterStop(FFmpegNativeEncoder videoFFmpeg) { try { videoFFmpeg.Dispose(); } catch (Exception disposeEx) { Melon.Logger.Error($"Encoder dispose after the render thread stopped also failed: {disposeEx.Message}"); } UnregisterEncoder(videoFFmpeg); } /// /// Takes frames from ImageQueue in passCount order and sends each one to the encoder, followed by the audio /// samples of that frame. Waits on FrameAvailableEvent (50ms) while no frame is ready. /// Returns when the render is aborted (checked before every frame, frames left in ImageQueue are released /// by the cleanup after the thread ends) or all requested frames have been sent after endRequested. /// private void EncodeQueuedFrames(FFmpegNativeEncoder videoFFmpeg) { while (true) { if (IsRenderAborted) return; if (TryDequeueNextFrame(out var frame)) { FrameDequeuedEvent.Set(); SendVideoFrame(videoFFmpeg, frame); WriteAudioForFrame(videoFFmpeg); continue; } if (ShouldStopEncodeLoop()) return; FrameAvailableEvent.WaitOne(50); } } /// /// Removes the frame whose key equals passCount from ImageQueue and advances passCount. Render thread. /// While the stage is below Render, passCount jumps forward to the first queued key when that key is greater: /// frames are only captured in the Render stage, so this happens after the stage was set back to None at the end /// of the render while the thread drains the queue, and a frame whose readback has not arrived yet is skipped. /// Returns false when the queue is empty or the next frame has not arrived yet. /// private bool TryDequeueNextFrame(out CapturedFrame frame) { frame = default; lock (ImageQueue) { if (ImageQueue.Count == 0) return false; var firstKey = ImageQueue.Keys[0]; if (firstKey > passCount && Instance.stage < RenderStage.Render) { passCount = firstKey; } if (firstKey != passCount) return false; frame = ImageQueue[firstKey]; ImageQueue.RemoveAt(0); passCount++; return true; } } /// /// Sends one captured frame to the encoder and releases its buffer (managed: ArrayPool, native: readback state). /// A frame without data is skipped (not counted in framesSent). Render thread. /// private void SendVideoFrame(FFmpegNativeEncoder videoFFmpeg, in CapturedFrame frame) { if (frame.ManagedBuffer != null) { if (frame.ManagedBuffer.Length == 0) return; videoFFmpeg.SendDataFromBuffer(frame.ManagedBuffer, frame.Length, frame.IsColorConverted); Interlocked.Increment(ref framesSent); ArrayPool.Shared.Return(frame.ManagedBuffer); return; } if (frame.NativePointer == IntPtr.Zero || frame.Length <= 0) return; videoFFmpeg.SendDataFromNativeBuffer(frame.NativePointer, frame.Length, frame.IsColorConverted); Interlocked.Increment(ref framesSent); if (frame.ReadbackState != null) { _frameReader?.RecycleState(frame.ReadbackState); } } /// /// Mixes the audio of the frame that was just sent (index passCount - 1) and writes it to the encoder. /// The sample count per frame comes from SyncCalculator (sample rate / effective video FPS, with the remainder /// carried to later frames). Mixing waits until the main Unity thread has committed the audio commands up to that time. /// Does nothing when audio rendering is off. Render thread. /// /// The mixer was cancelled (render aborted) while waiting. private void WriteAudioForFrame(FFmpegNativeEncoder videoFFmpeg) { if (!Configuration.AudioConfig.RenderAudio || SyncCalculator is not { } syncCalculator) return; var channels = Configuration.AudioConfig.AudioChannels; var samplesNeeded = syncCalculator.GetNextFrameSamples(); var audioBuffer = ArrayPool.Shared.Rent(samplesNeeded * channels); try { AudioMixer?.GenerateAudioForFrame(audioBuffer, samplesNeeded); videoFFmpeg.WriteAudioSamples(audioBuffer, samplesNeeded); } finally { ArrayPool.Shared.Return(audioBuffer); } } /// /// True when the render is aborted, or when the end was requested and every requested frame has been sent. /// private bool ShouldStopEncodeLoop() { if (IsRenderAborted) return true; return endRequested && passCount > Volatile.Read(ref lastRequestedVideoFrameIndex); } /// Removes the encoder from the cleanup list and clears _activeVideoFFmpeg. Called by the render thread when it finishes. private void UnregisterEncoder(FFmpegNativeEncoder videoFFmpeg) { lock (_allVideoFFmpegInstances) { _allVideoFFmpegInstances.Remove(videoFFmpeg); } _activeVideoFFmpeg = null; } /// /// Stops the render without finishing the file (cancelled, or failed when MarkFailed was called first), deletes /// the unfinished file, reports the result through OnComplete, restores the environment and returns to the editor. /// Called by the ESC handler and on failures. Main thread. /// public void CancelRender() { ResetRenderState(success: false); StopFFmpeg(false); EndRender(); } /// /// Requests the end of encoding. Main thread. /// true: completes the audio commands and starts StopRenderCoroutine, which waits for /// the render thread to send the remaining frames and finish the file. /// false: marks the render as cancelled (unless failed), cancels the audio waits, waits up /// to 2 s for the render thread, disposes the encoder, releases the queued frames, the frame reader, the hooks and /// the debug monitor, restores the camera and no-fail flags, deletes the unfinished file and calls OnComplete with /// a failure result. Does not restore time, input offset or the scene (EndRender / RestoreRenderEnvironment do). /// private void StopFFmpeg(bool success) { endRequested = true; if (!success) { MarkCancelled(); } // The abort state is set first: the render thread reads it when its audio wait is cancelled. if (success) AudioMixer?.Complete(); else AudioMixer?.Cancel(); FrameAvailableEvent.Set(); skipPressToStart = false; _renderSetup = false; if (_escapeHandlerObj) { Destroy(_escapeHandlerObj); _escapeHandlerObj = null; } if (success) StartCoroutine(StopRenderCoroutine()); else { var reason = IsRenderFailed ? $"FAILED ({_failureReason})" : "CANCELLED"; Melon.Logger.Error($"Rendering {reason}. Safely cleaning up state and resources."); // 1. Wait (up to 2s) for the render background thread to see the abort flag and terminate if (RenderThread is { IsAlive: true }) { RenderThread.Join(2000); } // 2. Release the encoder. The render thread normally released it already (_activeVideoFFmpeg is then null). // When the thread is still running after the 2 s wait, the encoder is disposed while that thread may still use it. 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(); ResetFrameHookState(); // 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 the incomplete video file of this render if (DeleteIncompleteOutputFile()) { CurrentOutPath = null; } var result = BuildResult(false, BuildAbortMessage()); TriggerOnComplete(result); } } /// /// Successful end: waits (one check per frame) until the render thread reports endSuccessful or has died. /// On success calls OnComplete with a success result and EndRender; when the thread died without finishing /// the file, the render fails and is cancelled (file deleted). /// private IEnumerator StopRenderCoroutine() { yield return new WaitUntil(() => endSuccessful || RenderThread is not { IsAlive: true }); if (!endSuccessful) { MarkFailed("the render thread terminated unexpectedly."); CancelRender(); yield break; } var result = BuildResult(success: true); TriggerOnComplete(result); EndRender(); } /// Restores the render environment and loads the level editor (scnEditor). private void EndRender() { RestoreRenderEnvironment(); ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnEditor"); Cursor.visible = true; } /// /// Undoes what the render setup changed (time scale and capture delta time, input offset, autoplay, custom level /// paths, queued frames, frame reader, frame hooks, audio mixer and spectrum, RenderContext options), without /// leaving the scene. /// private void RestoreRenderEnvironment() { 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(); ResetFrameHookState(); AudioSpectrumGenerator.Clear(); AudioMixer?.Dispose(); AudioMixer = null; SyncCalculator = null; RenderContext.Reset(); } /// /// Stops the render stopwatch and creates the result: frames sent, video duration (frames / effective video FPS), /// wall-clock render time, and the output path and file size on success. /// 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); } /// Invokes RenderContext.OnComplete; an exception from the callback is logged. private static void TriggerOnComplete(RenderResult result) { try { RenderContext.OnComplete.Invoke(result); } catch (Exception err) { Melon.Logger.Error($"OnComplete callback caused error: {err}"); } } /// Invokes RenderContext.OnLastUpdate; an exception from the callback is logged. private static void TriggerOnLastUpdate() { try { RenderContext.OnLastUpdate.Invoke(); } catch (Exception err) { Melon.Logger.Error($"OnLastUpdate callback caused error: {err}"); } } }