diff --git a/ADOFAIRenderer.csproj b/ADOFAIRenderer.csproj index a276d07..2591e4a 100644 --- a/ADOFAIRenderer.csproj +++ b/ADOFAIRenderer.csproj @@ -6,6 +6,7 @@ MSB3277 false true + true diff --git a/Core/RenderManager.cs b/Core/RenderManager.cs index cc0d710..e17fb48 100644 --- a/Core/RenderManager.cs +++ b/Core/RenderManager.cs @@ -111,8 +111,8 @@ namespace ADOFAIRenderer.Core public long wonFrame = -1; // Private Instance Fields - private VideoFFmpeg? _activeVideoFFmpeg; - private readonly List _allVideoFFmpegInstances = new List(); + private FFmpegNativeEncoder? _activeVideoFFmpeg; + private readonly List _allVideoFFmpegInstances = new List(); private int _originalInputOffset; private GameObject? _debugMonitorObj; private GameObject? _escapeHandlerObj; @@ -136,8 +136,8 @@ namespace ADOFAIRenderer.Core if (_activeVideoFFmpeg != null) { - Melon.Logger.Msg("게임 종료 또는 RenderManager 파괴 감지: FFmpeg 렌더 프로세스를 강제 종료합니다."); - _activeVideoFFmpeg.Kill(); + Melon.Logger.Msg("게임 종료 또는 RenderManager 파괴 감지: FFmpeg 네이티브 인코더 리소스를 해제합니다."); + _activeVideoFFmpeg.Dispose(); _activeVideoFFmpeg = null; } @@ -145,12 +145,12 @@ namespace ADOFAIRenderer.Core { if (_allVideoFFmpegInstances.Count > 0) { - Melon.Logger.Msg($"게임 종료 감지: 생성된 모든 FFmpeg 프로세스({_allVideoFFmpegInstances.Count}개)를 강제 수거 및 종료합니다."); + Melon.Logger.Msg($"게임 종료 감지: 생성된 모든 FFmpeg 인코더 인스턴스({_allVideoFFmpegInstances.Count}개)를 수거 및 해제합니다."); foreach (var instance in _allVideoFFmpegInstances) { try { - instance.Kill(); + instance.Dispose(); } catch { @@ -400,33 +400,8 @@ namespace ADOFAIRenderer.Core 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"); @@ -456,18 +431,14 @@ namespace ADOFAIRenderer.Core var qp = Configuration.QP; var codecStr = Configuration.Codec; - var selectedCodec = VideoFFmpeg.Codec.H264_Default; - if (Enum.TryParse(codecStr, true, out var parsedCodec)) + var selectedCodec = Codec.H264; + 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) + if (selectedCodec == Codec.AV1 || selectedCodec == Codec.VP9) { ext = ".webm"; } @@ -478,11 +449,23 @@ namespace ADOFAIRenderer.Core RenderThread = new Thread(() => { - VideoFFmpeg? videoFFmpeg = null; + FFmpegNativeEncoder? videoFFmpeg = null; try { var effectiveVideoFps = Math.Min(videoFps, targetFps); - videoFFmpeg = new VideoFFmpeg(Configuration.RenderWidth, Configuration.RenderHeight, effectiveVideoFps, videoFps, selectedCodec); + var settings = new FFmpegNativeEncoder.EncoderSettings + { + Width = Configuration.RenderWidth, + Height = Configuration.RenderHeight, + FPS = effectiveVideoFps, + Codec = selectedCodec, + Mode = bitrateMode, + QualityValue = qp, + TargetBitrate = bitrate, + MaxBitrate = maxBitrate + }; + + videoFFmpeg = new FFmpegNativeEncoder(settings); SetupCaptureDirectory(); @@ -491,7 +474,7 @@ namespace ADOFAIRenderer.Core { _allVideoFFmpegInstances.Add(videoFFmpeg); } - videoFFmpeg.Start(_currentOutPath, bitrateMode, bitrate, maxBitrate, qp); + videoFFmpeg.Start(_currentOutPath); while (true) { @@ -550,8 +533,15 @@ namespace ADOFAIRenderer.Core Thread.Sleep(1); } - if (isRenderCancelled) videoFFmpeg.Kill(); - else videoFFmpeg.Close(); + if (isRenderCancelled) + { + videoFFmpeg.Dispose(); + } + else + { + videoFFmpeg.End(); + videoFFmpeg.Dispose(); + } lock (_allVideoFFmpegInstances) { @@ -564,7 +554,7 @@ namespace ADOFAIRenderer.Core catch (Exception ex) { Melon.Logger.Error($"FFmpeg 렌더링 스레드 오류: {ex}"); - _activeVideoFFmpeg?.Kill(); + videoFFmpeg?.Dispose(); if (videoFFmpeg != null) { lock (_allVideoFFmpegInstances) @@ -612,13 +602,20 @@ namespace ADOFAIRenderer.Core { Melon.Logger.Error("렌더링이 실패했거나 취소되었습니다. 상태 및 리소스를 안전하게 청소합니다."); + // 1. 렌더링 백그라운드 스레드가 큐에 유입된 잔여 프레임 처리를 마무리하고 종료될 때까지 대기 + if (RenderThread is { IsAlive: true }) + { + RenderThread.Join(2000); + } + + // 2. 스레드 종료 보장 후, 안전하게 FFmpeg 네이티브 인코더 리소스 해제 if (_activeVideoFFmpeg != null) { - _activeVideoFFmpeg.Kill(16384); + _activeVideoFFmpeg.Dispose(); _activeVideoFFmpeg = null; } - // 1. 메모리 누수 방지: 큐에 렌트된 임시 프레임 버퍼 풀 반환 및 클리어 + // 3. 메모리 누수 방지: 큐에 렌트된 임시 프레임 버퍼 풀 반환 및 클리어 lock (ImageQueue) { foreach (var kvp in ImageQueue.Values) @@ -644,13 +641,7 @@ namespace ADOFAIRenderer.Core _debugMonitorObj = null; } - // 2. 렌더링 스레드가 큐 비어 있음을 감지하고 break 하도록 대기 - if (RenderThread is { IsAlive: true }) - { - RenderThread.Join(2000); - } - - // 3. 인게임 카메라 및 렌더링용 RTCam 상태 안전 원복 + // 4. 인게임 카메라 및 렌더링용 RTCam 상태 안전 원복 if (scrCamera.instance) { scrCamera.instance.forceRTCam = false; @@ -660,14 +651,14 @@ namespace ADOFAIRenderer.Core if (hook) Destroy(hook); } - // 4. 인게임 실패 방지 플래그 원상 복구 + // 5. 인게임 실패 방지 플래그 원상 복구 if (scrController.instance) { scrController.instance.noFail = false; scrController.instance.noFailInfiniteMargin = false; } - // 5. 취소된 미완성 임시 동영상 파일 즉각 삭제 + // 6. 취소된 미완성 임시 동영상 파일 즉각 삭제 if (!string.IsNullOrEmpty(_currentOutPath) && File.Exists(_currentOutPath)) { try diff --git a/UI/RenderButton.cs b/UI/RenderButton.cs index 79514fc..c3743aa 100644 --- a/UI/RenderButton.cs +++ b/UI/RenderButton.cs @@ -177,18 +177,18 @@ namespace ADOFAIRenderer.UI if (bitrateMode == RateControlMode.CBR || bitrateMode == RateControlMode.VBR) { - if (!int.TryParse(_bitrateInput.text, out bitrate) || bitrate <= 0) + if (!int.TryParse(_bitrateInput.text, out bitrate) || bitrate <= 0 || bitrate > 1_000_000) { - Melon.Logger.Error("올바르지 않은 Bitrate 값입니다. 0보다 큰 정수여야 합니다."); + Melon.Logger.Error("올바르지 않은 Bitrate 값입니다. 1에서 1,000,000 (kbps) 사이의 정수여야 합니다."); return; } } if (bitrateMode == RateControlMode.VBR) { - if (!int.TryParse(_maxBitrateInput.text, out maxBitrate) || maxBitrate <= 0) + if (!int.TryParse(_maxBitrateInput.text, out maxBitrate) || maxBitrate <= 0 || maxBitrate > 1_000_000) { - Melon.Logger.Error("올바르지 않은 Max Bitrate 값입니다. 0보다 큰 정수여야 합니다."); + Melon.Logger.Error("올바르지 않은 Max Bitrate 값입니다. 1에서 1,000,000 (kbps) 사이의 정수여야 합니다."); return; } if (maxBitrate < bitrate) diff --git a/Util/FFmpeg/FFmpegNativeEncoder.cs b/Util/FFmpeg/FFmpegNativeEncoder.cs index 0c63a54..0206549 100644 --- a/Util/FFmpeg/FFmpegNativeEncoder.cs +++ b/Util/FFmpeg/FFmpegNativeEncoder.cs @@ -1,47 +1,174 @@ using System; using FFmpeg.AutoGen; +using MelonLoader; using Unity.Collections; using Unity.Collections.LowLevel.Unsafe; namespace ADOFAIRenderer.Util.FFmpeg { - public unsafe class FFmpegNativeEncoder : IDisposable + /// + /// FFmpeg의 C 네이티브 API를 직접 호출(P/Invoke)하여 비디오를 인코딩하는 클래스입니다. + /// + /// ── 전체 동작 흐름 (Lifecycle) ────────────────────────────────────── + /// + /// 1. 생성자: 인코딩 설정(해상도, FPS, 코덱, 품질 등)을 저장합니다. + /// + /// 2. Start(outputPath): + /// a) 출력 컨테이너(mp4 등)를 위한 AVFormatContext 생성 + /// b) 비디오 스트림(AVStream) 추가 + /// c) 하드웨어 인코더(NVENC → AMF → QSV) → 소프트웨어 인코더(libx264 등)를 + /// 순서대로 시도하여 사용 가능한 인코더를 선택 + /// d) 파일 헤더 작성 및 프레임/스케일러 버퍼 초기화 + /// + /// 3. WriteFrame*(): 매 프레임마다 호출 + /// a) Unity RGBA 원본 프레임을 sws_scale로 NV12/YUV420P로 변환 + /// b) (QSV hw_frames 모드일 때) GPU 서피스로 업로드 + /// c) avcodec_send_frame → avcodec_receive_packet → 파일 쓰기 + /// + /// 4. End(): 인코더에 남아 있는 지연 프레임(delayed frames)을 플러시하고 트레일러 작성 + /// + /// 5. Dispose(): 모든 FFmpeg 리소스(컨텍스트, 프레임, 스케일러, I/O) 해제 + /// + /// ── 참고 문서 ────────────────────────────────────────────────────── + /// • FFmpeg 인코딩 개요: https://ffmpeg.org/doxygen/trunk/group__lavf__encoding.html + /// • Send/Receive 인코딩 API: https://ffmpeg.org/doxygen/trunk/group__lavc__encdec.html + /// • 하드웨어 가속 개요: https://ffmpeg.org/doxygen/trunk/group__lavu__hwcontext.html + /// • HW 가속 위키: https://trac.ffmpeg.org/wiki/HWAccelIntro + /// + public sealed unsafe class FFmpegNativeEncoder : IDisposable { + /// + /// 인코더에 전달할 설정값을 담는 구조체입니다. + /// public struct EncoderSettings { + /// 출력 비디오의 가로 해상도 (픽셀). public int Width; + + /// 출력 비디오의 세로 해상도 (픽셀). public int Height; + + /// 초당 프레임 수 (예: 60.0, 29.97 등). public double FPS; - + + /// 사용할 비디오 코덱 종류 (H264, H265, AV1, VP9). public Codec Codec; + + /// 비트레이트 제어 모드 (CQP, CBR, VBR). public RateControlMode Mode; - + + /// CQP 모드에서 사용되는 품질 값. 값이 낮을수록 고품질. (예: H.264 CRF 18 ≈ 시각적 무손실) public int QualityValue; // CRF + + /// VBR/CBR 모드의 목표 비트레이트 (kbps 단위). FFmpeg 내부에서 bps로 변환됩니다. public int TargetBitrate; // VBR + + /// VBR 모드의 최대 비트레이트 (kbps 단위). 피크 품질 한도를 결정합니다. public int MaxBitrate; // VBR } - - // FFmpeg 네이티브 컨텍스트 및 구조체 포인터들 - private AVFormatContext* _pFormatContext = null; - private AVCodecContext* _pCodecContext = null; - private AVStream* _pStream = null; - - // 프레임 버퍼 및 스케일러 포인터들 - private AVFrame* _pSrcFrame = null; // Unity에서 넘어온 원시 RGBA 프레임용 - private AVFrame* _pDstFrame = null; // 인코더가 요구하는 YUV420P 프레임용 - private SwsContext* _pSwsContext = null; // RGBA -> YUV420P 변환 스케일러 - - private long _frameIndex = 0; - private bool _isInitialized = false; - private bool _disposed = false; - + + // ────────────────────────────────────────────────────────────── + // FFmpeg 핵심 구조체 포인터들 + // ────────────────────────────────────────────────────────────── + + /// + /// 출력 컨테이너(mp4, mkv 등)의 포맷 정보를 관리하는 컨텍스트. + /// 파일 헤더/트레일러 작성, 패킷 기록 등에 사용됩니다. + /// 참고: https://ffmpeg.org/doxygen/trunk/group__lavf__core.html + /// + private AVFormatContext* _pFormatContext = null; + + /// + /// 코덱(인코더)의 상태를 관리하는 컨텍스트. + /// 해상도, 픽셀 포맷, 프레임레이트, 품질 설정 등 인코딩에 필요한 모든 파라미터가 여기에 설정됩니다. + /// 참고: https://ffmpeg.org/doxygen/trunk/structAVCodecContext.html + /// + private AVCodecContext* _pCodecContext = null; + + /// + /// 출력 파일 내의 비디오 스트림. + /// 하나의 컨테이너(포맷 컨텍스트)에 여러 스트림(비디오, 오디오 등)이 포함될 수 있는데, + /// 여기서는 비디오 스트림 하나만 사용합니다. + /// 참고: https://ffmpeg.org/doxygen/trunk/group__lavf__core.html + /// + private AVStream* _pStream = null; + + // ────────────────────────────────────────────────────────────── + // 프레임 버퍼 및 색공간 변환기(스케일러) 포인터들 + // ────────────────────────────────────────────────────────────── + + /// + /// Unity에서 캡처한 원본 RGBA 프레임 데이터를 가리키는 프레임 구조체. + /// 실제 픽셀 데이터를 소유하지 않고, data[0]과 linesize[0]로 외부 버퍼를 참조합니다. + /// 참고: https://ffmpeg.org/doxygen/trunk/group__lavu__frame.html + /// + private AVFrame* _pSrcFrame = null; + + /// + /// 인코더가 요구하는 픽셀 포맷(YUV420P 또는 NV12)으로 변환된 프레임. + /// av_frame_get_buffer()로 실제 픽셀 데이터 메모리가 할당됩니다. + /// + private AVFrame* _pDstFrame = null; + + /// + /// libswscale의 색공간/해상도 변환 컨텍스트. + /// RGBA(Unity) → YUV420P/NV12(인코더) 변환을 수행합니다. + /// 해상도 변환 없이 색공간만 변환할 때도 sws_scale을 사용합니다. + /// 참고: https://ffmpeg.org/doxygen/trunk/group__libsws.html + /// + private SwsContext* _pSwsContext = null; + + /// 현재까지 인코딩한 프레임의 누적 인덱스. PTS(Presentation Time Stamp) 값으로 사용됩니다. + private long _frameIndex; + + /// Start()가 성공적으로 완료되어 인코딩이 가능한 상태인지 여부. + private bool _isInitialized; + + /// Dispose()가 이미 호출되었는지 여부. 중복 해제를 방지합니다. + private bool _disposed; + + /// 멀티스레딩 환경에서 인코더 상태 및 리소스 접근을 동기화하기 위한 락 객체. + private readonly object _lock = new object(); + + /// + /// QSV 인코더에서 hw_frames_ctx 모드(GPU 프레임 풀)가 활성화되었는지 여부. + /// true이면 매 프레임마다 NV12 → QSV 하드웨어 서피스 업로드가 필요합니다. + /// false이면 시스템 메모리의 NV12 프레임을 직접 인코더에 전달합니다. + /// + private bool _isQsvEncoder; + + /// + /// 인코더의 목표 픽셀 포맷. + /// - 일반 인코더(NVENC, AMF, 소프트웨어): YUV420P (가장 범용적인 4:2:0 크로마 서브샘플링) + /// - QSV 인코더: NV12 (Y 평면 + UV 인터리브 평면, Intel 하드웨어가 선호하는 포맷) + /// + /// 참고 - 주요 픽셀 포맷 관계: + /// RGBA (Unity 원본, 4채널 8비트) + /// ↓ sws_scale 변환 + /// YUV420P (Y/U/V 3개 평면, 크로마 1/4 해상도) 또는 + /// NV12 (Y 1개 평면 + UV 인터리브 1개 평면, 크로마 1/4 해상도) + /// + /// 참고: https://ffmpeg.org/doxygen/trunk/pixfmt_8h.html + /// + private AVPixelFormat _dstPixFmt = AVPixelFormat.AV_PIX_FMT_YUV420P; + + /// 생성 시 전달받은 인코딩 설정값 (불변). private readonly EncoderSettings _settings; - + public FFmpegNativeEncoder(EncoderSettings settings) { _settings = settings; } + // ================================================================ + // 유틸리티 메서드 + // ================================================================ + + /// + /// 애플리케이션의 Codec enum 값을 FFmpeg 내부에서 사용하는 AVCodecID로 변환합니다. + /// AVCodecID는 "어떤 코덱인지(예: H.264, AV1)"를 식별하는 값이고, + /// 실제 인코더 구현체(예: libx264, h264_nvenc)는 별도로 선택됩니다. + /// // ReSharper disable once InconsistentNaming private AVCodecID GetAVCodecID() { @@ -55,12 +182,78 @@ namespace ADOFAIRenderer.Util.FFmpeg }; } + /// + /// 선택된 코덱에 대해 시도할 인코더 이름 목록을 우선순위 순으로 반환합니다. + /// + /// 우선순위: 하드웨어 인코더(NVENC → AMF → QSV) → 소프트웨어 인코더(libx264 등) + /// + /// - nvenc: NVIDIA GPU 전용 하드웨어 인코더 (CUDA 기반) + /// - amf: AMD GPU 전용 하드웨어 인코더 (Advanced Media Framework) + /// - qsv: Intel GPU/iGPU 전용 하드웨어 인코더 (Quick Sync Video, Media SDK) + /// - lib*: CPU 기반 소프트웨어 인코더 (항상 사용 가능하지만 느림) + /// + /// Start()에서 이 목록을 순회하며 첫 번째로 구동 가능한 인코더를 사용합니다. + /// + private string[] GetEncoderCandidates() + { + return _settings.Codec switch + { + Codec.H264 => new[] { "h264_nvenc", "h264_amf", "h264_qsv", "libx264" }, + Codec.H265 => new[] { "hevc_nvenc", "hevc_amf", "hevc_qsv", "libx265" }, + Codec.AV1 => new[] { "av1_nvenc", "av1_amf", "av1_qsv", "libsvtav1", "libaom-av1" }, + Codec.VP9 => new[] { "vp9_nvenc", "vp9_qsv", "libvpx-vp9" }, + _ => Array.Empty() + }; + } + + /// + /// FFmpeg 에러 코드(음수 정수)를 사람이 읽을 수 있는 문자열로 변환합니다. + /// 예: -1 → "Operation not permitted" + /// + /// 내부적으로 av_strerror()를 호출하여 스택 할당된 버퍼에 메시지를 기록합니다. + /// 참고: https://ffmpeg.org/doxygen/trunk/group__lavu__error.html + /// + private string GetFFmpegErrorString(int errnum) + { + const ulong bufferSize = 1024; + byte* buffer = stackalloc byte[(int)bufferSize]; + if (ffmpeg.av_strerror(errnum, buffer, bufferSize) >= 0) + { + return System.Runtime.InteropServices.Marshal.PtrToStringAnsi((IntPtr)buffer) ?? $"알 수 없는 에러 (Code: {errnum})"; + } + return $"알 수 없는 에러 (Code: {errnum})"; + } + + // ================================================================ + // 인코더 초기화 (Start) + // ================================================================ + + /// + /// 인코딩 파이프라인 전체를 초기화합니다. + /// + /// 수행 단계: + /// 1. 출력 파일의 컨테이너 포맷 컨텍스트(AVFormatContext) 생성 + /// 2. 비디오 스트림(AVStream) 추가 + /// 3. 인코더 후보 순회 → 코덱 컨텍스트(AVCodecContext) 설정 → 인코더 열기 + /// 4. 스트림에 코덱 파라미터 복사 + /// 5. 출력 파일 열기 및 헤더 작성 + /// 6. 프레임 버퍼(AVFrame) 및 색공간 변환기(SwsContext) 초기화 + /// + /// 실패 시 Dispose()를 호출하여 부분 초기화된 리소스를 모두 정리한 뒤 예외를 재발생시킵니다. + /// + /// 출력 비디오 파일 경로 (예: "C:\output\video.mp4") public void Start(string outputPath) { - if (_isInitialized) return; - - try + lock (_lock) { + if (_isInitialized || _disposed) return; + + try + { + // ── 1단계: 출력 컨테이너 포맷 컨텍스트 생성 ── + // avformat_alloc_output_context2는 파일 확장자(outputPath)를 보고 + // 적합한 출력 포맷(mp4, mkv 등)을 자동으로 선택합니다. + // 참고: https://ffmpeg.org/doxygen/trunk/group__lavf__core.html fixed (AVFormatContext** ppFormatCtx = &_pFormatContext) { ffmpeg.avformat_alloc_output_context2(ppFormatCtx, null, null, outputPath); @@ -68,65 +261,374 @@ namespace ADOFAIRenderer.Util.FFmpeg if (_pFormatContext == null) throw new Exception("출력 포맷 컨텍스트를 생성하지 못했습니다."); - var pCodec = ffmpeg.avcodec_find_encoder(GetAVCodecID()); - if (pCodec == null) throw new Exception("코덱 인코더를 찾을 수 없습니다."); - + // ── 2단계: 비디오 스트림 추가 ── + // 컨테이너 안에 비디오 트랙(스트림)을 하나 생성합니다. + // 오디오가 필요하면 여기서 오디오 스트림을 추가로 만들 수 있습니다. + // 참고: https://ffmpeg.org/doxygen/trunk/group__lavf__core.html _pStream = ffmpeg.avformat_new_stream(_pFormatContext, null); if (_pStream == null) throw new Exception("비디오 스트림을 생성하지 못했습니다."); - _pCodecContext = ffmpeg.avcodec_alloc_context3(pCodec); - _pCodecContext->width = _settings.Width; - _pCodecContext->height = _settings.Height; - _pCodecContext->pix_fmt = AVPixelFormat.AV_PIX_FMT_YUV420P; + // ── 3단계: 인코더 선택 및 열기 ── + // GetEncoderCandidates()가 반환하는 우선순위 목록을 순회하며 + // 첫 번째로 사용 가능한 인코더를 찾습니다. + var candidates = GetEncoderCandidates(); + bool openedSuccessfully = false; - var fpsRational = RationalUtil.Double2MaxPrecisionInt(_settings.FPS); - _pCodecContext->time_base = new AVRational - { num = fpsRational.Denominator, den = fpsRational.Numerator }; - _pCodecContext->framerate = new AVRational - { num = fpsRational.Numerator, den = fpsRational.Denominator }; - _pCodecContext->gop_size = (int)Math.Round(_settings.FPS * 2); - - _pCodecContext->max_b_frames = 3; - _pCodecContext->has_b_frames = 1; - - if ((_pFormatContext->oformat->flags & ffmpeg.AVFMT_GLOBALHEADER) != 0) + foreach (var encoderName in candidates) { - _pCodecContext->flags |= ffmpeg.AV_CODEC_FLAG_GLOBAL_HEADER; + // avcodec_find_encoder_by_name: 이름(예: "h264_nvenc")으로 인코더를 검색합니다. + // 해당 인코더가 빌드에 포함되어 있지 않으면 null을 반환합니다. + // 참고: https://ffmpeg.org/doxygen/trunk/group__lavc__encoding.html + var pCodec = ffmpeg.avcodec_find_encoder_by_name(encoderName); + if (pCodec == null) continue; + + // avcodec_alloc_context3: 선택된 인코더용 코덱 컨텍스트를 할당합니다. + // 이 컨텍스트에 해상도, 프레임레이트, 품질 등 모든 인코딩 파라미터를 설정합니다. + // 참고: https://ffmpeg.org/doxygen/trunk/group__lavc__core.html + _pCodecContext = ffmpeg.avcodec_alloc_context3(pCodec); + if (_pCodecContext == null) continue; + + // ── 기본 비디오 파라미터 설정 ── + _pCodecContext->width = _settings.Width; + _pCodecContext->height = _settings.Height; + + // ── 픽셀 포맷 설정 ── + // 비디오 인코더는 일반적으로 YUV420P 또는 NV12 형식의 입력을 기대합니다. + // + // • YUV420P: Y/U/V 3개의 독립 평면(plane). 대부분의 소프트웨어 인코더가 사용. + // • NV12: Y 평면 + UV 인터리브 평면. Intel QSV 등 하드웨어 인코더가 선호. + // • QSV 특수 사항: 코덱 컨텍스트의 pix_fmt은 AV_PIX_FMT_QSV(하드웨어 서피스 식별자)로, + // 실제 sws_scale 출력은 NV12(소프트웨어 포맷)로 설정합니다. + // FFmpeg이 내부적으로 NV12 ↔ QSV 서피스 간 매핑을 처리합니다. + // + // 참고: https://ffmpeg.org/doxygen/trunk/pixfmt_8h.html + AVPixelFormat selectedPixFmt = AVPixelFormat.AV_PIX_FMT_YUV420P; + if (encoderName.Contains("qsv")) + { + selectedPixFmt = AVPixelFormat.AV_PIX_FMT_NV12; + _pCodecContext->pix_fmt = AVPixelFormat.AV_PIX_FMT_QSV; + } + else + { + _pCodecContext->pix_fmt = selectedPixFmt; + } + + // ── 프레임레이트 및 타임베이스 설정 ── + // time_base: 프레임 간 시간 간격의 기본 단위. + // 예) 60fps → time_base = 1/60초 (num=1, den=60) + // PTS(Presentation Time Stamp) 값에 time_base를 곱하면 실제 시간(초)이 됩니다. + // PTS=150, time_base=1/60 → 150 * (1/60) = 2.5초 + // + // framerate: 인코더에 힌트를 주는 프레임레이트 값 (time_base의 역수). + // + // RationalUtil: FPS(double)를 분수 형태(분자/분모)로 변환합니다. + // 예) 29.97 → 30000/1001 (NTSC 표준) + // + // 참고: https://ffmpeg.org/doxygen/trunk/structAVCodecContext.html + var fpsRational = RationalUtil.Double2MaxPrecisionInt(_settings.FPS); + _pCodecContext->time_base = new AVRational + { num = fpsRational.Denominator, den = fpsRational.Numerator }; + _pCodecContext->framerate = new AVRational + { num = fpsRational.Numerator, den = fpsRational.Denominator }; + + // ── GOP(Group of Pictures) 크기 설정 ── + // GOP: 키프레임(I-frame) 간격. 예) gop_size=120, 60fps → 2초마다 키프레임. + // 키프레임은 완전한 이미지를 담고 있어 탐색(seek) 지점이 됩니다. + // 너무 크면 탐색 정밀도가 떨어지고, 너무 작으면 파일 크기가 증가합니다. + _pCodecContext->gop_size = (int)Math.Round(_settings.FPS * 2); + + // ── B-프레임 비활성화 ── + // B-프레임(양방향 예측 프레임): 앞뒤 프레임을 참조하여 압축률을 높이지만, + // 인코딩 지연(latency)이 증가하고 일부 인코더에서 호환성 문제가 발생할 수 있습니다. + // 실시간 녹화 시나리오에서는 비활성화하여 지연을 최소화합니다. + // + // 참고: has_b_frames는 인코더가 내부적으로 설정하는 읽기 전용 필드이므로 + // 직접 세팅하지 않습니다. + // + // SVT-AV1 특수 사항: max_b_frames를 AVCodecContext에서 직접 설정하면 + // "bad parameter" 에러가 발생하므로, svtav1-params를 통해 제어합니다. + // pred-struct=1: Low Delay (IPPP) 구조로 B-프레임 비활성화 + if (encoderName == "libsvtav1") + { + ffmpeg.av_opt_set(_pCodecContext->priv_data, "svtav1-params", "pred-struct=1", 0); + } + else + { + _pCodecContext->max_b_frames = 0; + } + + // ── 글로벌 헤더 플래그 ── + // 일부 컨테이너 포맷(mp4 등)은 코덱의 설정 정보(SPS/PPS 등)를 + // 각 패킷이 아닌 파일 헤더에 한 번만 기록하길 요구합니다. + // AVFMT_GLOBALHEADER 플래그가 설정된 포맷이면 AV_CODEC_FLAG_GLOBAL_HEADER를 켜서 + // 인코더가 extradata에 설정 정보를 저장하도록 합니다. + // 참고: https://ffmpeg.org/doxygen/trunk/avformat_8h.html + if ((_pFormatContext->oformat->flags & ffmpeg.AVFMT_GLOBALHEADER) != 0) + { + _pCodecContext->flags |= ffmpeg.AV_CODEC_FLAG_GLOBAL_HEADER; + } + + // ── 품질/비트레이트 설정 적용 ── + ApplyQualitySettings(encoderName); + + // ── QSV 인코더 하드웨어 디바이스 초기화 ── + // QSV(Quick Sync Video)는 Intel GPU의 고정 기능 인코딩 하드웨어를 사용합니다. + // FFmpeg에서 QSV를 사용하려면 하드웨어 디바이스 컨텍스트를 생성해야 합니다. + // + // 아래에서 3가지 전략을 순차적으로 시도합니다: + // 전략 1) child_device_type=d3d11va 옵션으로 QSV 디바이스 직접 생성 + // → FFmpeg 내부에서 D3D11 디바이스를 자식으로 생성하여 QSV에 연결 + // 전략 2) D3D11VA 디바이스를 먼저 만들고, QSV 디바이스를 파생(derive) + // → 일부 드라이버에서 전략 1이 실패할 때 유효 + // 전략 3) QSV 디바이스 직접 생성 (레거시 방식) + // → 구형 드라이버/시스템에서 폴백 + // + // 디바이스 확보 후, GPU 프레임 풀 활용을 시도합니다: + // hw_frames_ctx: GPU VRAM에 프레임 풀을 사전 할당 (제로 카피, 최고 성능) + // hw_device_ctx: 시스템 메모리에서 인코더가 직접 프레임을 읽음 (호환성 우수) + // + // 참고: https://ffmpeg.org/doxygen/trunk/group__lavu__hwcontext.html + if (encoderName.Contains("qsv")) + { + AVBufferRef* hwDeviceCtx = null; + bool qsvReady = false; + int err; + + // ── 전략 1: child_device_type=d3d11va 옵션 ── + // av_hwdevice_ctx_create에 "child_device_type" 딕셔너리를 전달하면 + // FFmpeg이 내부적으로 D3D11 디바이스를 생성하고 QSV 세션에 연결합니다. + // 대부분의 최신 Intel 드라이버에서 가장 안정적으로 동작합니다. + { + AVDictionary* opts = null; + ffmpeg.av_dict_set(&opts, "child_device_type", "d3d11va", 0); + err = ffmpeg.av_hwdevice_ctx_create(&hwDeviceCtx, AVHWDeviceType.AV_HWDEVICE_TYPE_QSV, null, opts, 0); + ffmpeg.av_dict_free(&opts); + if (err >= 0) + { + qsvReady = true; + Melon.Logger.Msg($"{encoderName}: child_device_type=d3d11va 방식으로 QSV 디바이스 생성 성공"); + } + else + { + Melon.Logger.Warning($"{encoderName}: child_device_type 방식 실패 — {GetFFmpegErrorString(err)} (Code: {err})"); + } + } + + // ── 전략 2: D3D11VA → QSV 파생(derive) ── + // D3D11VA 디바이스를 먼저 생성한 뒤, + // av_hwdevice_ctx_create_derived로 QSV 디바이스를 파생시킵니다. + // 두 디바이스가 동일한 GPU를 공유하게 됩니다. + if (!qsvReady) + { + AVBufferRef* d3d11DeviceCtx = null; + err = ffmpeg.av_hwdevice_ctx_create(&d3d11DeviceCtx, AVHWDeviceType.AV_HWDEVICE_TYPE_D3D11VA, null, null, 0); + if (err >= 0) + { + err = ffmpeg.av_hwdevice_ctx_create_derived(&hwDeviceCtx, AVHWDeviceType.AV_HWDEVICE_TYPE_QSV, d3d11DeviceCtx, 0); + if (err >= 0) + { + qsvReady = true; + Melon.Logger.Msg($"{encoderName}: D3D11VA → QSV 파생 방식으로 디바이스 생성 성공"); + } + ffmpeg.av_buffer_unref(&d3d11DeviceCtx); + } + } + + // ── 전략 3: QSV 직접 생성 (레거시) ── + if (!qsvReady) + { + err = ffmpeg.av_hwdevice_ctx_create(&hwDeviceCtx, AVHWDeviceType.AV_HWDEVICE_TYPE_QSV, null, null, 0); + if (err >= 0) qsvReady = true; + } + + if (!qsvReady) + { + Melon.Logger.Warning($"{encoderName}: 모든 QSV 디바이스 생성 전략 실패. 다음 후보 코덱을 테스트합니다."); + { + var pCtx = _pCodecContext; + ffmpeg.avcodec_free_context(&pCtx); + } + _pCodecContext = null; + continue; + } + + // ── hw_frames_ctx 시도: GPU 프레임 풀 사전 생성 ── + // AVHWFramesContext는 GPU VRAM에 프레임 풀을 미리 할당하여 + // 매 프레임마다 메모리 할당/해제 오버헤드를 제거합니다. + // + // 장점: 제로 카피 가능, 최고 성능 + // 단점: GPU/드라이버에 따라 해상도/포맷 제약으로 실패할 수 있음 + // (예: Intel Arc에서 특정 텍스처 크기 제약 → E_INVALIDARG) + // + // initial_pool_size: 미리 할당할 프레임 수. 인코더 파이프라인 깊이에 맞춤. + // 해상도를 32 배수로 정렬: QSV 하드웨어가 요구하는 텍스처 정렬 조건. + // + // 참고: + // AVHWFramesContext: https://ffmpeg.org/doxygen/trunk/structAVHWFramesContext.html + // av_hwframe_ctx_alloc: https://ffmpeg.org/doxygen/trunk/group__lavu__hwcontext.html + bool framesCtxReady = false; + AVBufferRef* hwFramesRef = ffmpeg.av_hwframe_ctx_alloc(hwDeviceCtx); + if (hwFramesRef != null) + { + var framesCtx = (AVHWFramesContext*)hwFramesRef->data; + framesCtx->format = AVPixelFormat.AV_PIX_FMT_QSV; // 하드웨어 서피스 포맷 + framesCtx->sw_format = AVPixelFormat.AV_PIX_FMT_NV12; // 소프트웨어 측 포맷 + framesCtx->width = (_settings.Width + 31) & ~31; // 32 배수 정렬 (비트 마스크) + framesCtx->height = (_settings.Height + 31) & ~31; // 32 배수 정렬 (비트 마스크) + framesCtx->initial_pool_size = 4; // 프레임 풀 크기 + + err = ffmpeg.av_hwframe_ctx_init(hwFramesRef); + if (err >= 0) + { + // 성공: 코덱 컨텍스트에 hw_frames_ctx를 연결합니다. + // av_buffer_ref로 참조 카운트를 증가시켜 hwFramesRef 해제 후에도 유효하게 합니다. + _pCodecContext->hw_frames_ctx = ffmpeg.av_buffer_ref(hwFramesRef); + _isQsvEncoder = true; + framesCtxReady = true; + Melon.Logger.Msg($"{encoderName}: hw_frames_ctx 초기화 성공 (GPU 프레임 풀 모드)"); + } + else + { + Melon.Logger.Warning($"{encoderName}: hw_frames_ctx 실패 — {GetFFmpegErrorString(err)} (Code: {err}). hw_device_ctx 모드로 폴백합니다."); + } + ffmpeg.av_buffer_unref(&hwFramesRef); + } + + // ── hw_frames_ctx 실패 시: hw_device_ctx + NV12 시스템 메모리 폴백 ── + // hw_device_ctx 모드에서는 인코더가 시스템 메모리(RAM)의 NV12 프레임을 + // 직접 받아서 내부적으로 GPU로 업로드합니다. + // hw_frames_ctx보다 약간 느리지만 호환성이 훨씬 좋습니다. + // + // pix_fmt을 NV12로 변경: 하드웨어 서피스(QSV) 대신 소프트웨어 포맷 직접 사용. + // _isQsvEncoder = false: WriteFrame()에서 수동 업로드를 건너뜁니다. + if (!framesCtxReady) + { + _pCodecContext->pix_fmt = AVPixelFormat.AV_PIX_FMT_NV12; + _pCodecContext->hw_device_ctx = ffmpeg.av_buffer_ref(hwDeviceCtx); + _isQsvEncoder = false; // 시스템 메모리 모드이므로 프레임 업로드 불필요 + } + + // hwDeviceCtx의 소유권은 코덱 컨텍스트로 이전했으므로 + // 로컬 참조를 해제합니다 (참조 카운트 관리). + // 참고: https://ffmpeg.org/doxygen/trunk/group__lavu__buffer.html + ffmpeg.av_buffer_unref(&hwDeviceCtx); + } + + // ── 인코더별 속도 프리셋 지정 ── + // 프리셋은 인코딩 속도 ↔ 압축 효율 간의 트레이드오프를 결정합니다. + // 빠른 프리셋: 인코딩 빠르지만 파일 크기 증가 + // 느린 프리셋: 인코딩 느리지만 같은 품질에서 파일 크기 감소 + // + // 참고: https://ffmpeg.org/doxygen/trunk/group__opt__set__funcs.html + if (encoderName.Contains("nvenc")) + { + // NVENC 프리셋 범위: p1(최고속) ~ p7(최고품질). p4 = 중간(default) + ffmpeg.av_opt_set(_pCodecContext->priv_data, "preset", "p4", 0); + } + else if (encoderName.Contains("libx264") || encoderName.Contains("libx265")) + { + // x264/x265 프리셋: ultrafast, superfast, veryfast, faster, fast, medium, slow, ... + // CPU 인코딩은 병목이 되기 쉬우므로 빠른 프리셋 사용 + ffmpeg.av_opt_set(_pCodecContext->priv_data, "preset", "veryfast", 0); + } + else if (encoderName == "libsvtav1") + { + // SVT-AV1 프리셋: 0(최고 품질/최저 속도) ~ 13(최고 속도/최저 품질) + ffmpeg.av_opt_set(_pCodecContext->priv_data, "preset", "10", 0); + } + else if (encoderName == "libaom-av1") + { + // libaom cpu-used: 0(최고 품질) ~ 8(최고 속도) + ffmpeg.av_opt_set(_pCodecContext->priv_data, "cpu-used", "6", 0); + // "realtime" usage: 단일 패스, 저지연 인코딩 모드 + ffmpeg.av_opt_set(_pCodecContext->priv_data, "usage", "realtime", 0); + } + // 다른 인코더들(AMF, QSV 등)은 기본 프리셋을 그대로 사용합니다. + // 지원하지 않는 옵션을 설정하면 에러가 발생할 수 있으므로 건너뜁니다. + + // ── 인코더 열기 ── + // avcodec_open2: 위에서 설정한 모든 파라미터를 기반으로 인코더를 초기화합니다. + // 성공하면 0 이상을 반환하고, 이후 avcodec_send_frame/avcodec_receive_packet으로 + // 프레임 인코딩이 가능해집니다. + // 참고: https://ffmpeg.org/doxygen/trunk/group__lavc__core.html + int openResult = ffmpeg.avcodec_open2(_pCodecContext, pCodec, null); + if (openResult >= 0) + { + bool isHardware = encoderName.Contains("nvenc") || encoderName.Contains("amf") || encoderName.Contains("qsv"); + string accelStatus = isHardware ? "하드웨어 가속 활성화" : "소프트웨어 CPU 사용"; + Melon.Logger.Msg($"비디오 인코더 구동 성공: {encoderName} ({accelStatus})"); + _dstPixFmt = selectedPixFmt; + openedSuccessfully = true; + break; + } + + // 구동 실패 시 코덱 컨텍스트를 해제하고 다음 인코더를 시도합니다. + Melon.Logger.Warning($"{encoderName} 하드웨어 인코더 구동 실패: {GetFFmpegErrorString(openResult)} (Code: {openResult}). 다음 후보 코덱을 테스트합니다."); + var pCodecCtx = _pCodecContext; + ffmpeg.avcodec_free_context(&pCodecCtx); + _pCodecContext = null; } - ApplyQualitySettings(); - ffmpeg.av_opt_set(_pCodecContext->priv_data, "preset", "slow", 0); - - if (ffmpeg.avcodec_open2(_pCodecContext, pCodec, null) < 0) - throw new Exception("지정된 설정으로 코덱을 여는 데 실패했습니다."); + if (!openedSuccessfully || _pCodecContext == null) + { + throw new Exception("사용 가능한 비디오 인코더(하드웨어 가속 및 CPU 소프트웨어 전체)의 구동에 실패했습니다."); + } + // ── 4단계: 코덱 파라미터를 스트림에 복사 ── + // 코덱 컨텍스트에 설정된 파라미터(해상도, 코덱 ID, 프로파일 등)를 + // 스트림의 codecpar에 복사합니다. 컨테이너 헤더에 기록될 정보입니다. + // 참고: https://ffmpeg.org/doxygen/trunk/group__lavc__core.html if (ffmpeg.avcodec_parameters_from_context(_pStream->codecpar, _pCodecContext) < 0) throw new Exception("코덱 파라미터를 스트림에 복사하는 데 실패했습니다."); + // ── 5단계: 출력 파일 I/O 열기 ── + // AVFMT_NOFILE 플래그가 없는 포맷은 실제 파일 I/O가 필요합니다. + // avio_open으로 파일을 쓰기 모드로 열고, 포맷 컨텍스트의 pb(I/O 컨텍스트)에 연결합니다. + // 참고: https://ffmpeg.org/doxygen/trunk/group__lavf__io.html if ((_pFormatContext->oformat->flags & ffmpeg.AVFMT_NOFILE) == 0) { if (ffmpeg.avio_open(&_pFormatContext->pb, outputPath, ffmpeg.AVIO_FLAG_WRITE) < 0) throw new Exception($"출력 파일 {outputPath}를 열 수 없습니다."); } + // 파일 헤더(ftyp, moov 등 컨테이너 메타데이터) 작성. + // 이 시점 이후부터 av_interleaved_write_frame으로 패킷을 쓸 수 있습니다. + // 참고: https://ffmpeg.org/doxygen/trunk/group__lavf__encoding.html if (ffmpeg.avformat_write_header(_pFormatContext, null) < 0) throw new Exception("파일 헤더를 작성하는 데 실패했습니다."); + // ── 6단계: 프레임 버퍼 및 색공간 변환기 초기화 ── + // av_frame_alloc: AVFrame 구조체를 힙에 할당합니다 (픽셀 데이터는 아직 없음). + // 참고: https://ffmpeg.org/doxygen/trunk/group__lavu__frame.html _pSrcFrame = ffmpeg.av_frame_alloc(); _pDstFrame = ffmpeg.av_frame_alloc(); if (_pSrcFrame == null || _pDstFrame == null) throw new Exception("프레임 구조체를 할당하지 못했습니다."); - _pDstFrame->format = (int)AVPixelFormat.AV_PIX_FMT_YUV420P; + // 목적지 프레임의 형식과 크기를 설정한 뒤 실제 픽셀 버퍼를 할당합니다. + // av_frame_get_buffer: format, width, height에 맞는 메모리를 할당하고 + // data[]/linesize[] 포인터를 설정합니다. 두 번째 인자 0은 기본 정렬을 의미합니다. + _pDstFrame->format = (int)_dstPixFmt; _pDstFrame->width = _settings.Width; _pDstFrame->height = _settings.Height; if (ffmpeg.av_frame_get_buffer(_pDstFrame, 0) < 0) - throw new Exception("YUV420P 프레임 버퍼를 할당하는 데 실패했습니다."); + throw new Exception($"{_dstPixFmt} 프레임 버퍼를 할당하는 데 실패했습니다."); + // sws_getContext: 소프트웨어 스케일러(색공간 변환기) 초기화. + // 입력: RGBA (Unity 캡처 포맷, 4바이트/픽셀) + // 출력: YUV420P 또는 NV12 (인코더 요구 포맷) + // + // 여기서는 해상도 변환 없이 색공간만 변환하지만, sws_scale은 + // 동시에 리사이즈도 수행할 수 있습니다. + // + // SWS_BILINEAR: 바이리니어 보간법. 색공간만 변환할 때는 품질 차이가 미미하며, + // 속도와 품질의 균형이 좋은 기본 옵션입니다. + // + // 참고: https://ffmpeg.org/doxygen/trunk/group__libsws.html _pSwsContext = ffmpeg.sws_getContext( _settings.Width, _settings.Height, AVPixelFormat.AV_PIX_FMT_RGBA, - _settings.Width, _settings.Height, AVPixelFormat.AV_PIX_FMT_YUV420P, + _settings.Width, _settings.Height, _dstPixFmt, (int)SwsFlags.SWS_BILINEAR, null, null, null); if (_pSwsContext == null) @@ -136,108 +638,324 @@ namespace ADOFAIRenderer.Util.FFmpeg } catch (Exception) { + // 초기화 도중 실패 시 부분 할당된 리소스를 모두 정리합니다. Dispose(); throw; } + } } - private void ApplyQualitySettings() + + // ================================================================ + // 품질/비트레이트 설정 + // ================================================================ + + /// + /// 사용자가 선택한 비트레이트 제어 모드(CBR, VBR, CQP)에 따라 + /// 코덱 컨텍스트에 품질 관련 파라미터를 설정합니다. + /// + /// ── 비트레이트 제어 모드 설명 ── + /// + /// • CBR (Constant Bitrate): 일정한 비트레이트를 유지. 스트리밍에 적합. + /// → bit_rate = rc_max_rate = rc_min_rate (모두 동일) + /// + /// • VBR (Variable Bitrate): 장면 복잡도에 따라 비트레이트가 변동. + /// → bit_rate = 목표값, rc_max_rate = 최대 허용값 + /// + /// • CQP (Constant Quantization Parameter): 고정 양자화 값 사용. + /// → 값이 낮을수록 고품질/대용량, 높을수록 저품질/소용량 + /// → 각 인코더마다 설정 방식이 다름 (아래 참조) + /// + /// 참고 - 인코더별 CQP 옵션: + /// NVENC: "qp" (단일 값으로 I/P/B 프레임 모두 적용) + /// AMF: "qp_i" + "qp_p" (I프레임, P프레임 별도 설정) + /// QSV: "global_quality" (Intel 고유 옵션) + /// 소프트웨어: "crf" (Constant Rate Factor, 더 스마트한 품질 제어) + /// + /// 참고: https://ffmpeg.org/doxygen/trunk/structAVCodecContext.html + /// + private void ApplyQualitySettings(string encoderName) { switch (_settings.Mode) { case RateControlMode.CBR: - _pCodecContext->bit_rate = _pCodecContext->rc_max_rate = _pCodecContext->rc_min_rate = _settings.TargetBitrate; - _pCodecContext->rc_buffer_size = _settings.TargetBitrate; + // kbps → bps 변환 (FFmpeg의 bit_rate 필드는 bps 단위) + // CBR에서는 목표/최대/최소 비트레이트를 모두 동일하게 설정합니다. + // rc_buffer_size는 비트레이트 버퍼링의 크기를 결정합니다. + _pCodecContext->bit_rate = _pCodecContext->rc_max_rate = _pCodecContext->rc_min_rate = _settings.TargetBitrate * 1000L; + _pCodecContext->rc_buffer_size = _settings.TargetBitrate * 1000; - ffmpeg.av_opt_set(_pCodecContext->priv_data, "nal-hrd", "cbr", 0); + // nal-hrd=cbr: x264/x265 전용 옵션. NAL HRD(Hypothetical Reference Decoder) 규격에 + // 맞는 CBR 비트스트림을 생성합니다. 스트리밍 호환성을 위해 필요할 수 있습니다. + if (encoderName.Contains("libx264") || encoderName.Contains("libx265")) + { + ffmpeg.av_opt_set(_pCodecContext->priv_data, "nal-hrd", "cbr", 0); + } break; - + case RateControlMode.VBR: - _pCodecContext->bit_rate = _settings.TargetBitrate; + // kbps → bps 변환 + _pCodecContext->bit_rate = _settings.TargetBitrate * 1000L; + + // MaxBitrate가 TargetBitrate보다 큰 경우 그대로 사용, + // 그렇지 않으면 TargetBitrate의 1.5배를 최대값으로 설정합니다. var maxRate = _settings.MaxBitrate > _settings.TargetBitrate - ? _settings.MaxBitrate : (int)(_settings.TargetBitrate * 1.5); + ? _settings.MaxBitrate * 1000L : (long)(_settings.TargetBitrate * 1500L); _pCodecContext->rc_max_rate = maxRate; - _pCodecContext->rc_buffer_size = maxRate * 2; + // 버퍼 크기를 최대 비트레이트의 2배로 설정하여 + // 순간적인 비트레이트 스파이크를 수용합니다. + _pCodecContext->rc_buffer_size = (int)(maxRate * 2); break; - + case RateControlMode.CQP: + // 기본값 18: H.264에서 "시각적으로 거의 무손실"에 가까운 품질. + // 다른 코덱에서도 비슷한 수준의 품질을 제공합니다. var qpValue = _settings.QualityValue > 0 ? _settings.QualityValue : 18; - ffmpeg.av_opt_set(_pCodecContext->priv_data, "qp", qpValue.ToString(), 0); + + if (encoderName.Contains("nvenc")) + { + // NVENC: "qp" 옵션 하나로 I/P/B 프레임 모두에 동일한 QP 적용 + ffmpeg.av_opt_set(_pCodecContext->priv_data, "qp", qpValue.ToString(), 0); + } + else if (encoderName.Contains("amf")) + { + // AMF: I프레임(qp_i)과 P프레임(qp_p)의 QP를 별도로 설정해야 합니다. + // B-프레임은 비활성화했으므로 qp_b는 설정하지 않습니다. + ffmpeg.av_opt_set(_pCodecContext->priv_data, "qp_i", qpValue.ToString(), 0); + ffmpeg.av_opt_set(_pCodecContext->priv_data, "qp_p", qpValue.ToString(), 0); + } + else if (encoderName.Contains("qsv")) + { + // QSV: "global_quality" 옵션으로 ICQ(Intelligent Constant Quality) 모드 사용 + ffmpeg.av_opt_set(_pCodecContext->priv_data, "global_quality", qpValue.ToString(), 0); + } + else // 소프트웨어 인코더 (libx264, libx265, libvpx-vp9 등) + { + // CRF(Constant Rate Factor): QP보다 더 스마트한 품질 제어. + // 장면 복잡도에 따라 비트레이트를 자동 조절하여 일정한 시각적 품질을 유지합니다. + // 값 범위: 0(무손실) ~ 51(최저품질). 일반적으로 18~28 사이를 사용합니다. + ffmpeg.av_opt_set(_pCodecContext->priv_data, "crf", qpValue.ToString(), 0); + } break; default: throw new ArgumentOutOfRangeException(); } } + // ================================================================ + // 프레임 쓰기 (Write Frame) + // ================================================================ + + /// + /// Unity에서 캡처한 RGBA 픽셀 데이터(byte* 포인터)를 받아 한 프레임을 인코딩합니다. + /// + /// Unity의 텍스처는 Bottom-Left origin(화면 좌하단이 (0,0))이지만, + /// 비디오 표준은 Top-Left origin(화면 좌상단이 (0,0))을 사용합니다. + /// 이를 해결하기 위해 "음수 stride 트릭"을 사용합니다: + /// + /// ── 음수 stride(linesize) 트릭 ── + /// • data[0]을 버퍼의 마지막 행(맨 아래줄) 시작 위치로 설정 + /// • linesize[0]을 -stride(음수)로 설정 + /// → sws_scale이 행을 역순으로 읽어 자연스럽게 상하 반전(vflip) 수행 + /// → 추가 메모리 복사나 별도의 반전 연산이 필요 없어 매우 효율적 + /// + /// stride = Width × 4 (RGBA는 픽셀당 4바이트) + /// + /// Unity RGBA 픽셀 데이터의 시작 주소 public void WriteFrameFromBuffer(byte* buffer) { - if (!_isInitialized) throw new InvalidOperationException("인코더가 초기화되지 않았습니다."); - - // Unity의 Bottom-Left origin 좌표계를 FFmpeg의 Top-Left origin 좌표계로 맞추기 위해 - // 포인터를 마지막 행의 시작 위치로 옮기고 stride(linesize)를 음수(-)로 주어 상하 반전(vflip)을 수행합니다. - // 추가적인 메모리 카피나 GPU 연산 없이 sws_scale 내부에서 초고속으로 상하가 반전됩니다. - int stride = _settings.Width * 4; - _pSrcFrame->data[0] = buffer + stride * (_settings.Height - 1); - _pSrcFrame->linesize[0] = -stride; - _pSrcFrame->width = _settings.Width; - _pSrcFrame->height = _settings.Height; - _pSrcFrame->format = (int)AVPixelFormat.AV_PIX_FMT_RGBA; - - WriteFrame(); - } - - public void WriteFrameFromNativeBuffer(NativeArray buffer) - { - if (!_isInitialized) throw new InvalidOperationException("인코더가 초기화되지 않았습니다."); - WriteFrameFromBuffer((byte*)buffer.GetUnsafeReadOnlyPtr()); - } - - public void SendDataFromNativeBuffer(IntPtr pointer, int length) - { - if (!_isInitialized) throw new InvalidOperationException("인코더가 초기화되지 않았습니다."); - WriteFrameFromBuffer((byte*)pointer.ToPointer()); - } - - public void SendDataFromBuffer(byte[] buffer, int length) - { - if (!_isInitialized) throw new InvalidOperationException("인코더가 초기화되지 않았습니다."); - fixed (byte* pBuffer = buffer) + lock (_lock) { - WriteFrameFromBuffer(pBuffer); + if (_disposed || !_isInitialized) return; + + int stride = _settings.Width * 4; // RGBA = 4 bytes per pixel + _pSrcFrame->data[0] = buffer + stride * (_settings.Height - 1); // 마지막 행의 시작 주소 + _pSrcFrame->linesize[0] = -stride; // 음수 stride로 상하 반전 + _pSrcFrame->width = _settings.Width; + _pSrcFrame->height = _settings.Height; + _pSrcFrame->format = (int)AVPixelFormat.AV_PIX_FMT_RGBA; + + WriteFrame(); } } - private void WriteFrame() + /// + /// Unity NativeArray<byte>에서 읽기 전용 포인터를 얻어 프레임을 인코딩합니다. + /// NativeArray는 Unity의 네이티브 메모리 할당자로 관리되는 배열로, + /// GC(가비지 컬렉터) 압력 없이 대량의 데이터를 다룰 수 있습니다. + /// + public void WriteFrameFromNativeBuffer(NativeArray buffer) { - ffmpeg.sws_scale( - _pSwsContext, - _pSrcFrame->data, _pSrcFrame->linesize, - 0, _settings.Height, - _pDstFrame->data, _pDstFrame->linesize - ); - - // 3. 타임스탬프 설정 - _pDstFrame->pts = _frameIndex++; - - // 4. 인코더로 프레임 전달 및 패킷 쓰기 - SendFrameAndWritePackets(_pDstFrame); + lock (_lock) + { + if (_disposed || !_isInitialized) return; + WriteFrameFromBuffer((byte*)buffer.GetUnsafeReadOnlyPtr()); + } } + /// + /// IntPtr(네이티브 포인터)로부터 프레임을 인코딩합니다. + /// 외부 네이티브 플러그인이나 커스텀 캡처 소스에서 사용할 수 있습니다. + /// + public void SendDataFromNativeBuffer(IntPtr pointer, int length) + { + lock (_lock) + { + if (_disposed || !_isInitialized) return; + WriteFrameFromBuffer((byte*)pointer.ToPointer()); + } + } + + /// + /// 매니지드 byte[]에서 프레임을 인코딩합니다. + /// fixed 키워드로 GC가 배열을 이동하지 못하도록 고정(pin)한 뒤 포인터를 전달합니다. + /// + public void SendDataFromBuffer(byte[] buffer, int length) + { + lock (_lock) + { + if (_disposed || !_isInitialized) return; + fixed (byte* pBuffer = buffer) + { + WriteFrameFromBuffer(pBuffer); + } + } + } + + /// + /// 실제 프레임 인코딩의 핵심 로직. + /// + /// 처리 흐름: + /// 1. av_frame_make_writable: 목적지 프레임의 버퍼가 쓰기 가능한지 확인 + /// (인코더가 이전 프레임의 버퍼를 아직 참조 중이면 새 버퍼를 할당) + /// 2. sws_scale: RGBA → YUV420P/NV12 색공간 변환 + /// 3. (QSV hw_frames 모드) av_hwframe_transfer_data: NV12 → QSV 하드웨어 서피스 업로드 + /// 4. PTS 설정 후 인코더에 전달 + /// + /// 참고: https://ffmpeg.org/doxygen/trunk/group__lavu__frame.html (av_frame_make_writable) + /// 참고: https://ffmpeg.org/doxygen/trunk/group__libsws.html (sws_scale) + /// + private void WriteFrame() + { + // 인코더가 이전 프레임의 버퍼를 참조 중일 수 있으므로 + // 쓰기 가능한 상태를 보장합니다. 참조가 걸려 있으면 내부적으로 새 버퍼를 할당합니다. + ffmpeg.av_frame_make_writable(_pDstFrame); + + // ── 색공간 변환: RGBA → YUV420P 또는 NV12 ── + // sws_scale 파라미터 설명: + // _pSwsContext: 사전 초기화된 변환 컨텍스트 + // _pSrcFrame->data: 입력 데이터 평면 배열 (RGBA는 data[0]만 사용) + // _pSrcFrame->linesize: 입력의 각 평면 stride (바이트/행) + // 0: 처리 시작할 소스의 첫 번째 행 인덱스 + // _settings.Height: 처리할 행 수 (= 전체 높이) + // _pDstFrame->data: 출력 데이터 평면 배열 + // _pDstFrame->linesize: 출력의 각 평면 stride + ffmpeg.sws_scale( + _pSwsContext, + _pSrcFrame->data, _pSrcFrame->linesize, + 0, _settings.Height, + _pDstFrame->data, _pDstFrame->linesize + ); + + if (_isQsvEncoder) + { + // ── QSV hw_frames_ctx 모드: 시스템 메모리 → GPU 서피스 업로드 ── + // + // av_hwframe_get_buffer: hw_frames_ctx의 프레임 풀에서 빈 하드웨어 프레임을 가져옵니다. + // av_hwframe_transfer_data: NV12 소프트웨어 프레임의 데이터를 QSV 서피스로 복사(업로드)합니다. + // + // 이 과정을 거치면 인코더가 GPU 메모리에서 직접 프레임을 읽어 + // PCIe 대역폭 사용을 최소화할 수 있습니다. + // + // 참고: https://ffmpeg.org/doxygen/trunk/group__lavu__hwcontext.html + AVFrame* hwFrame = ffmpeg.av_frame_alloc(); + try + { + int ret = ffmpeg.av_hwframe_get_buffer(_pCodecContext->hw_frames_ctx, hwFrame, 0); + if (ret < 0) + throw new Exception($"QSV 하드웨어 프레임 버퍼 할당 실패 — {GetFFmpegErrorString(ret)} (Code: {ret})"); + + ret = ffmpeg.av_hwframe_transfer_data(hwFrame, _pDstFrame, 0); + if (ret < 0) + throw new Exception($"QSV 프레임 업로드 실패 — {GetFFmpegErrorString(ret)} (Code: {ret})"); + + // PTS(Presentation Time Stamp): 이 프레임이 표시되어야 하는 시점. + // _frameIndex를 1씩 증가시키며, 실제 시간은 PTS × time_base로 계산됩니다. + hwFrame->pts = _frameIndex++; + SendFrameAndWritePackets(hwFrame); + } + finally + { + // 하드웨어 프레임의 참조를 해제합니다. + // 프레임 풀에서 가져온 프레임이므로, 해제하면 풀로 반환됩니다. + var pHw = hwFrame; + ffmpeg.av_frame_free(&pHw); + } + } + else + { + // ── 일반 인코더 (소프트웨어 또는 hw_device_ctx 모드) ── + // 소프트웨어 프레임을 직접 인코더에 전달합니다. + // hw_device_ctx 모드인 경우, 인코더가 내부적으로 GPU 업로드를 처리합니다. + _pDstFrame->pts = _frameIndex++; + SendFrameAndWritePackets(_pDstFrame); + } + } + + // ================================================================ + // 인코더 Send/Receive 및 패킷 쓰기 + // ================================================================ + + /// + /// FFmpeg의 Send/Receive 패턴으로 프레임을 인코딩하고 결과 패킷을 파일에 기록합니다. + /// + /// ── Send/Receive API 설명 ── + /// FFmpeg 3.x부터 도입된 비동기 인코딩 API입니다: + /// + /// avcodec_send_frame(ctx, frame) + /// → 인코더의 내부 큐에 프레임을 넣습니다. + /// → 인코더가 충분한 프레임을 모으면 인코딩을 시작합니다. + /// + /// avcodec_receive_packet(ctx, packet) + /// → 인코딩이 완료된 패킷(압축된 데이터)을 꺼냅니다. + /// → EAGAIN: 아직 출력이 준비되지 않음 (더 많은 프레임 필요) + /// → EOF: 인코딩 완료 (flush 시) + /// + /// 하나의 send_frame 호출에 대해 0개 이상의 패킷이 나올 수 있으므로 + /// receive_packet을 EAGAIN/EOF가 나올 때까지 반복합니다. + /// + /// ── 패킷 타임스탬프 처리 ── + /// av_packet_rescale_ts: 코덱의 time_base에서 스트림의 time_base로 PTS/DTS를 변환합니다. + /// 코덱과 컨테이너가 서로 다른 time_base를 사용할 수 있기 때문에 반드시 필요합니다. + /// 예) 코덱 time_base=1/60, 스트림 time_base=1/90000 → PTS 150 → PTS 225000 + /// + /// 참고: + /// Send/Receive API: https://ffmpeg.org/doxygen/trunk/group__lavc__encdec.html + /// 패킷: https://ffmpeg.org/doxygen/trunk/group__lavc__packet.html + /// 인터리브 쓰기: https://ffmpeg.org/doxygen/trunk/group__lavf__encoding.html + /// + /// 인코딩할 프레임. null을 전달하면 인코더 플러시(지연 프레임 출력). private void SendFrameAndWritePackets(AVFrame* pFrame) { + // 인코더 내부 큐에 프레임 전달 int response = ffmpeg.avcodec_send_frame(_pCodecContext, pFrame); if (response < 0) { throw new Exception($"인코더에 프레임을 전송하는 도중 에러가 발생했습니다. (Code: {response})"); } + // 인코딩 완료된 패킷을 모두 꺼내어 파일에 기록합니다. + // av_packet_alloc: 패킷 구조체를 힙에 할당합니다. AVPacket* pPacket = ffmpeg.av_packet_alloc(); try { while (true) { response = ffmpeg.avcodec_receive_packet(_pCodecContext, pPacket); + + // EAGAIN: 인코더가 더 많은 입력 프레임을 필요로 함 → 루프 종료 + // EOF: 인코더 플러시 완료 (모든 지연 프레임 출력됨) → 루프 종료 if (response == ffmpeg.AVERROR(ffmpeg.EAGAIN) || response == ffmpeg.AVERROR_EOF) { break; @@ -247,117 +965,185 @@ namespace ADOFAIRenderer.Util.FFmpeg throw new Exception($"인코더에서 패킷을 수신하는 도중 에러가 발생했습니다. (Code: {response})"); } - // 스트림 타임베이스로 패킷 PTS/DTS 재조정 + // 코덱 타임베이스 → 스트림 타임베이스로 PTS/DTS 변환 ffmpeg.av_packet_rescale_ts(pPacket, _pCodecContext->time_base, _pStream->time_base); pPacket->stream_index = _pStream->index; - // 파일에 패킷 쓰기 + // av_interleaved_write_frame: 패킷을 컨테이너에 기록합니다. + // "interleaved"는 여러 스트림(비디오+오디오)의 패킷을 시간 순서대로 + // 인터리빙하여 쓴다는 의미입니다. 비디오만 있어도 이 함수를 사용합니다. response = ffmpeg.av_interleaved_write_frame(_pFormatContext, pPacket); if (response < 0) { throw new Exception($"패킷을 파일에 쓰는 도중 에러가 발생했습니다. (Code: {response})"); } + // 패킷의 데이터 참조를 해제하여 다음 receive_packet에서 재사용할 수 있게 합니다. ffmpeg.av_packet_unref(pPacket); } } finally { + // 패킷 구조체 자체를 힙에서 해제합니다. ffmpeg.av_packet_free(&pPacket); } } + // ================================================================ + // 인코딩 종료 (End) + // ================================================================ + + /// + /// 인코딩을 완료합니다. + /// + /// 대부분의 인코더는 내부 버퍼에 프레임을 쌓아두고(delayed frames/지연 프레임), + /// 충분한 프레임이 모였을 때 인코딩을 수행합니다. End()에서는: + /// + /// 1. null 프레임을 보내 인코더를 "플러시 모드"로 전환합니다. + /// → 인코더가 내부 버퍼에 남은 모든 프레임을 인코딩하여 출력합니다. + /// → receive_packet이 EOF를 반환할 때까지 패킷을 꺼냅니다. + /// + /// 2. av_write_trailer: 컨테이너의 트레일러(파일 끝 메타데이터)를 작성합니다. + /// MP4의 경우 moov atom(인덱스 정보)이 여기서 완성됩니다. + /// ⚠️ 트레일러를 쓰지 않으면 파일이 손상되어 재생이 불가능합니다! + /// + /// 참고: https://ffmpeg.org/doxygen/trunk/group__lavf__encoding.html + /// public void End() { - if (!_isInitialized) return; - - // 1. 지연된 프레임 플러시 (null 프레임 전송) - try + lock (_lock) { - SendFrameAndWritePackets(null); - } - catch (Exception ex) - { - // 플러시 과정 중 발생한 에러 기록 - UnityEngine.Debug.LogError($"인코더 플러시 중 오류 발생: {ex.Message}"); - } + if (_disposed || !_isInitialized) return; - // 2. 트레일러 쓰기 - if (_pFormatContext != null) - { - ffmpeg.av_write_trailer(_pFormatContext); - } + // 1. 지연된 프레임 플러시 (null 프레임 전송으로 인코더에 "입력 끝" 신호) + try + { + SendFrameAndWritePackets(null); + } + catch (Exception ex) + { + // 플러시 과정 중 발생한 에러 기록 + UnityEngine.Debug.LogError($"인코더 플러시 중 오류 발생: {ex.Message}"); + } - _isInitialized = false; + // 2. 컨테이너 트레일러 작성 (moov atom 등 파일 종료 메타데이터) + if (_pFormatContext != null) + { + ffmpeg.av_write_trailer(_pFormatContext); + } + + _isInitialized = false; + } } + // ================================================================ + // 리소스 해제 (Dispose) + // ================================================================ + + /// + /// 모든 FFmpeg 네이티브 리소스를 해제합니다. + /// IDisposable 패턴을 구현하여 using 문이나 수동 Dispose() 호출을 지원합니다. + /// + /// ⚠️ FFmpeg의 모든 리소스는 네이티브 메모리(힙)에 할당되므로 + /// C#의 GC가 자동으로 회수하지 않습니다. 반드시 명시적으로 해제해야 합니다. + /// public void Dispose() { Dispose(true); + // 파이널라이저가 이미 정리된 리소스를 다시 해제하지 않도록 방지합니다. GC.SuppressFinalize(this); } - protected virtual void Dispose(bool disposing) - { - if (_disposed) return; - - // 만약 End()를 호출하지 않고 명시적으로 Dispose가 되는 경우도 있으므로 안전하게 End() 트리거 - if (_isInitialized) - { - try { End(); } catch { /* 무시 */ } - } - - // Sws 스케일러 해제 - if (_pSwsContext != null) - { - ffmpeg.sws_freeContext(_pSwsContext); - _pSwsContext = null; - } - - // 프레임 메모리 해제 - if (_pSrcFrame != null) - { - var pFrame = _pSrcFrame; - ffmpeg.av_frame_free(&pFrame); - _pSrcFrame = null; - } - if (_pDstFrame != null) - { - var pFrame = _pDstFrame; - ffmpeg.av_frame_free(&pFrame); - _pDstFrame = null; - } - - // 코덱 컨텍스트 해제 - if (_pCodecContext != null) - { - var pCodecCtx = _pCodecContext; - ffmpeg.avcodec_free_context(&pCodecCtx); - _pCodecContext = null; - } - - // 포맷 컨텍스트 및 I/O 닫기 - if (_pFormatContext != null) - { - if ((_pFormatContext->oformat->flags & ffmpeg.AVFMT_NOFILE) == 0) - { - var pPb = _pFormatContext->pb; - if (pPb != null) - { - ffmpeg.avio_closep(&pPb); - _pFormatContext->pb = null; - } - } - ffmpeg.avformat_free_context(_pFormatContext); - _pFormatContext = null; - } - - _disposed = true; - } - - ~FFmpegNativeEncoder() - { - Dispose(false); + /// + /// 실제 리소스 해제 로직. 해제 순서가 중요합니다: + /// + /// 1. End() 호출 (아직 안 했다면) → 지연 프레임 플러시 + 트레일러 작성 + /// 2. SwsContext (스케일러) → 독립적, 먼저 해제 가능 + /// 3. AVFrame (소스/목적지) → 코덱이 참조할 수 있으므로 코덱보다 먼저 해제 + /// 4. AVCodecContext (코덱) → hw_frames_ctx/hw_device_ctx도 함께 해제됨 + /// 5. AVFormatContext (포맷/I/O) → 파일 핸들 닫기 포함 + /// + /// 참고: + /// av_frame_free: https://ffmpeg.org/doxygen/trunk/group__lavu__frame.html + /// avcodec_free_context: https://ffmpeg.org/doxygen/trunk/group__lavc__core.html + /// avformat_free_context: https://ffmpeg.org/doxygen/trunk/group__lavf__core.html + /// avio_closep: https://ffmpeg.org/doxygen/trunk/group__lavf__io.html + /// + private void Dispose(bool disposing) + { + lock (_lock) + { + if (_disposed) return; + _disposed = true; + + // 만약 End()를 호출하지 않고 Dispose가 되는 경우도 있으므로 안전하게 End() 트리거 + if (_isInitialized) + { + try { End(); } catch { /* 무시 */ } + } + + // Sws 스케일러 해제 + if (_pSwsContext != null) + { + ffmpeg.sws_freeContext(_pSwsContext); + _pSwsContext = null; + } + + // 소스 프레임 메모리 해제 (Unity RGBA 데이터를 참조하는 프레임) + if (_pSrcFrame != null) + { + var pFrame = _pSrcFrame; + ffmpeg.av_frame_free(&pFrame); + _pSrcFrame = null; + } + + // 목적지 프레임 메모리 해제 (YUV420P/NV12 변환 결과 프레임) + if (_pDstFrame != null) + { + var pFrame = _pDstFrame; + ffmpeg.av_frame_free(&pFrame); + _pDstFrame = null; + } + + // 코덱 컨텍스트 해제 + // 이 함수는 내부적으로 hw_frames_ctx와 hw_device_ctx도 함께 해제합니다. + if (_pCodecContext != null) + { + var pCodecCtx = _pCodecContext; + ffmpeg.avcodec_free_context(&pCodecCtx); + _pCodecContext = null; + } + + // 포맷 컨텍스트 및 I/O 닫기 + if (_pFormatContext != null) + { + // AVFMT_NOFILE이 아닌 경우 avio를 수동으로 닫아야 합니다. + // (avio_open으로 열었으므로 avio_closep로 닫음) + if ((_pFormatContext->oformat->flags & ffmpeg.AVFMT_NOFILE) == 0) + { + var pPb = _pFormatContext->pb; + if (pPb != null) + { + ffmpeg.avio_closep(&pPb); + _pFormatContext->pb = null; + } + } + // 포맷 컨텍스트 해제 (내부의 스트림 등도 함께 해제됨) + ffmpeg.avformat_free_context(_pFormatContext); + _pFormatContext = null; + } + } + } + + /// + /// 파이널라이저(소멸자). Dispose()가 호출되지 않은 채로 GC에 의해 수집될 때 실행됩니다. + /// 네이티브 리소스 누수를 방지하는 안전장치입니다. + /// ⚠️ 파이널라이저는 실행 시점이 보장되지 않으므로, 반드시 using 문이나 + /// 명시적 Dispose() 호출로 리소스를 관리하세요. + /// + ~FFmpegNativeEncoder() + { + Dispose(false); } } } \ No newline at end of file