using System; using FFmpeg.AutoGen; using Unity.Collections; using Unity.Collections.LowLevel.Unsafe; namespace ADOFAIRenderer.Util.FFmpeg { public unsafe class FFmpegNativeEncoder : IDisposable { public struct EncoderSettings { public int Width; public int Height; public double FPS; public Codec Codec; public RateControlMode Mode; public int QualityValue; // CRF public int TargetBitrate; // VBR 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; private readonly EncoderSettings _settings; public FFmpegNativeEncoder(EncoderSettings settings) { _settings = settings; } // ReSharper disable once InconsistentNaming private AVCodecID GetAVCodecID() { return _settings.Codec switch { Codec.H264 => AVCodecID.AV_CODEC_ID_H264, Codec.H265 => AVCodecID.AV_CODEC_ID_HEVC, Codec.AV1 => AVCodecID.AV_CODEC_ID_AV1, Codec.VP9 => AVCodecID.AV_CODEC_ID_VP9, _ => throw new ArgumentOutOfRangeException() }; } public void Start(string outputPath) { if (_isInitialized) return; try { fixed (AVFormatContext** ppFormatCtx = &_pFormatContext) { ffmpeg.avformat_alloc_output_context2(ppFormatCtx, null, null, outputPath); } if (_pFormatContext == null) throw new Exception("출력 포맷 컨텍스트를 생성하지 못했습니다."); var pCodec = ffmpeg.avcodec_find_encoder(GetAVCodecID()); if (pCodec == null) throw new Exception("코덱 인코더를 찾을 수 없습니다."); _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; 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) { _pCodecContext->flags |= ffmpeg.AV_CODEC_FLAG_GLOBAL_HEADER; } ApplyQualitySettings(); ffmpeg.av_opt_set(_pCodecContext->priv_data, "preset", "slow", 0); if (ffmpeg.avcodec_open2(_pCodecContext, pCodec, null) < 0) throw new Exception("지정된 설정으로 코덱을 여는 데 실패했습니다."); if (ffmpeg.avcodec_parameters_from_context(_pStream->codecpar, _pCodecContext) < 0) throw new Exception("코덱 파라미터를 스트림에 복사하는 데 실패했습니다."); if ((_pFormatContext->oformat->flags & ffmpeg.AVFMT_NOFILE) == 0) { if (ffmpeg.avio_open(&_pFormatContext->pb, outputPath, ffmpeg.AVIO_FLAG_WRITE) < 0) throw new Exception($"출력 파일 {outputPath}를 열 수 없습니다."); } if (ffmpeg.avformat_write_header(_pFormatContext, null) < 0) throw new Exception("파일 헤더를 작성하는 데 실패했습니다."); _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; _pDstFrame->width = _settings.Width; _pDstFrame->height = _settings.Height; if (ffmpeg.av_frame_get_buffer(_pDstFrame, 0) < 0) throw new Exception("YUV420P 프레임 버퍼를 할당하는 데 실패했습니다."); _pSwsContext = ffmpeg.sws_getContext( _settings.Width, _settings.Height, AVPixelFormat.AV_PIX_FMT_RGBA, _settings.Width, _settings.Height, AVPixelFormat.AV_PIX_FMT_YUV420P, (int)SwsFlags.SWS_BILINEAR, null, null, null); if (_pSwsContext == null) throw new Exception("소프트웨어 스케일러를 초기화하는 데 실패했습니다."); _isInitialized = true; } catch (Exception) { Dispose(); throw; } } private void ApplyQualitySettings() { 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; ffmpeg.av_opt_set(_pCodecContext->priv_data, "nal-hrd", "cbr", 0); break; case RateControlMode.VBR: _pCodecContext->bit_rate = _settings.TargetBitrate; var maxRate = _settings.MaxBitrate > _settings.TargetBitrate ? _settings.MaxBitrate : (int)(_settings.TargetBitrate * 1.5); _pCodecContext->rc_max_rate = maxRate; _pCodecContext->rc_buffer_size = maxRate * 2; break; case RateControlMode.CQP: var qpValue = _settings.QualityValue > 0 ? _settings.QualityValue : 18; ffmpeg.av_opt_set(_pCodecContext->priv_data, "qp", qpValue.ToString(), 0); break; default: throw new ArgumentOutOfRangeException(); } } 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) { WriteFrameFromBuffer(pBuffer); } } private void WriteFrame() { ffmpeg.sws_scale( _pSwsContext, _pSrcFrame->data, _pSrcFrame->linesize, 0, _settings.Height, _pDstFrame->data, _pDstFrame->linesize ); // 3. 타임스탬프 설정 _pDstFrame->pts = _frameIndex++; // 4. 인코더로 프레임 전달 및 패킷 쓰기 SendFrameAndWritePackets(_pDstFrame); } private void SendFrameAndWritePackets(AVFrame* pFrame) { int response = ffmpeg.avcodec_send_frame(_pCodecContext, pFrame); if (response < 0) { throw new Exception($"인코더에 프레임을 전송하는 도중 에러가 발생했습니다. (Code: {response})"); } AVPacket* pPacket = ffmpeg.av_packet_alloc(); try { while (true) { response = ffmpeg.avcodec_receive_packet(_pCodecContext, pPacket); if (response == ffmpeg.AVERROR(ffmpeg.EAGAIN) || response == ffmpeg.AVERROR_EOF) { break; } else if (response < 0) { throw new Exception($"인코더에서 패킷을 수신하는 도중 에러가 발생했습니다. (Code: {response})"); } // 스트림 타임베이스로 패킷 PTS/DTS 재조정 ffmpeg.av_packet_rescale_ts(pPacket, _pCodecContext->time_base, _pStream->time_base); pPacket->stream_index = _pStream->index; // 파일에 패킷 쓰기 response = ffmpeg.av_interleaved_write_frame(_pFormatContext, pPacket); if (response < 0) { throw new Exception($"패킷을 파일에 쓰는 도중 에러가 발생했습니다. (Code: {response})"); } ffmpeg.av_packet_unref(pPacket); } } finally { ffmpeg.av_packet_free(&pPacket); } } public void End() { if (!_isInitialized) return; // 1. 지연된 프레임 플러시 (null 프레임 전송) try { SendFrameAndWritePackets(null); } catch (Exception ex) { // 플러시 과정 중 발생한 에러 기록 UnityEngine.Debug.LogError($"인코더 플러시 중 오류 발생: {ex.Message}"); } // 2. 트레일러 쓰기 if (_pFormatContext != null) { ffmpeg.av_write_trailer(_pFormatContext); } _isInitialized = false; } 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); } } }