From 93539307b16f07ee080cf34fb933980ad1c515017587537f8ea21c293c657ec6 Mon Sep 17 00:00:00 2001 From: copecone Date: Sun, 11 Oct 2026 23:53:41 +0900 Subject: [PATCH] Fix Incomplete Output File Handling --- Core/RenderManager.cs | 53 ++++++++++++++++++++++------- Util/FFmpeg/FFmpegNativeEncoder.cs | 54 +++++++++++++----------------- 2 files changed, 65 insertions(+), 42 deletions(-) diff --git a/Core/RenderManager.cs b/Core/RenderManager.cs index e41526b..0532887 100644 --- a/Core/RenderManager.cs +++ b/Core/RenderManager.cs @@ -226,6 +226,11 @@ internal class RenderManager : MonoBehaviour private GameObject? _debugMonitorObj; private GameObject? _escapeHandlerObj; 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; private bool _renderSetup; private void Awake() @@ -286,6 +291,32 @@ internal class RenderManager : MonoBehaviour { 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; + } } private void Update() @@ -634,6 +665,7 @@ internal class RenderManager : MonoBehaviour { _allVideoFFmpegInstances.Add(videoFFmpeg); } + Volatile.Write(ref _incompleteOutputPath, CurrentOutPath); videoFFmpeg.Start(CurrentOutPath); } catch (Exception) @@ -735,7 +767,13 @@ internal class RenderManager : MonoBehaviour FrameAvailableEvent.WaitOne(50); } - if (!IsRenderAborted) videoFFmpeg.End(); + 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(); lock (_allVideoFFmpegInstances) @@ -862,18 +900,9 @@ internal class RenderManager : MonoBehaviour scrController.instance.noFailInfiniteMargin = false; } - // 6. Immediately delete cancelled incomplete temporary video file - if (!string.IsNullOrEmpty(CurrentOutPath) && File.Exists(CurrentOutPath)) + // 6. Immediately delete the incomplete video file of this render + if (DeleteIncompleteOutputFile()) { - 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; } diff --git a/Util/FFmpeg/FFmpegNativeEncoder.cs b/Util/FFmpeg/FFmpegNativeEncoder.cs index bf37a9a..debd386 100644 --- a/Util/FFmpeg/FFmpegNativeEncoder.cs +++ b/Util/FFmpeg/FFmpegNativeEncoder.cs @@ -1520,8 +1520,13 @@ namespace ADOFAIRenderer.Util.FFmpeg /// 2. av_write_trailer: Writes container trailer (end-of-file metadata). /// For MP4, the moov atom (index info) is completed here. /// ⚠️ If trailer is not written, the file becomes corrupted and unplayable! + /// av_write_trailer also flushes the output buffer and returns the write error of the output (AVIOContext.error), if any. + /// + /// Throws an exception when a flush or the trailer fails (e.g. the disk is full), because the file is then incomplete. + /// End() runs once: after it returns or throws, further calls return immediately. /// /// Reference: https://ffmpeg.org/doxygen/trunk/group__lavf__encoding.html + /// Reference: https://github.com/FFmpeg/FFmpeg/blob/release/8.0/libavformat/mux.c (av_write_trailer) /// public void End() { @@ -1529,37 +1534,29 @@ namespace ADOFAIRenderer.Util.FFmpeg { if (_disposed || !_isInitialized) return; - // 1. Flush delayed frames from video encoder (send null frame as "end of input" signal) try { + // 1. Flush delayed frames from video encoder (send null frame as "end of input" signal) SendFrameAndWritePackets(null); - } - catch (Exception ex) - { - // Log error occurring during flush process - Melon.Logger.Error($"Error during video encoder flush: {ex.Message}"); - } - // 1.5. Flush remaining samples in audio FIFO + flush audio encoder - if (_audioEnabled) - { - try + // 1.5. Flush remaining samples in audio FIFO + flush audio encoder + if (_audioEnabled) { FlushAudioEncoder(); } - catch (Exception ex) + + // 2. Write container trailer (moov atom and file termination metadata) + if (_pFormatContext != null) { - Melon.Logger.Error($"Error during audio encoder flush: {ex.Message}"); + var ret = ffmpeg.av_write_trailer(_pFormatContext); + if (ret < 0) + throw new Exception($"Failed to write the container trailer — {GetFFmpegErrorString(ret)} (Code: {ret})"); } } - - // 2. Write container trailer (moov atom and file termination metadata) - if (_pFormatContext != null) + finally { - ffmpeg.av_write_trailer(_pFormatContext); + _isInitialized = false; } - - _isInitialized = false; } } @@ -1570,11 +1567,13 @@ namespace ADOFAIRenderer.Util.FFmpeg /// /// Actual resource cleanup logic. Cleanup order is important: /// - /// 1. Call End() (if not called yet) → flush delayed frames + write trailer - /// 2. SwsContext (scaler) → independent, can be freed first - /// 3. AVFrame (src/dst) → freed before codec as codec may reference them - /// 4. AVCodecContext (codec) → hw_frames_ctx/hw_device_ctx freed together - /// 5. AVFormatContext (format/IO) → includes closing file handle + /// 1. SwsContext (scaler) → independent, can be freed first + /// 2. AVFrame (src/dst) → freed before codec as codec may reference them + /// 3. AVCodecContext (codec) → hw_frames_ctx/hw_device_ctx freed together + /// 4. AVFormatContext (format/IO) → includes closing file handle + /// + /// Dispose does not finish the file (no encoder flush, no trailer). A file is complete only after End() returned. + /// Without End(), the output file is incomplete and is left to the caller (the renderer deletes it). /// /// Reference: /// av_frame_free: https://ffmpeg.org/doxygen/trunk/group__lavu__frame.html @@ -1588,12 +1587,7 @@ namespace ADOFAIRenderer.Util.FFmpeg { if (_disposed) return; _disposed = true; - - // Trigger End() safely in case Dispose is called without calling End() first - if (_isInitialized) - { - try { End(); } catch { /* Ignore */ } - } + _isInitialized = false; // Release Sws scaler if (_pSwsContext != null)