SHA256
273 lines
23 KiB
C#
273 lines
23 KiB
C#
using System;
|
|
using FFmpeg.AutoGen;
|
|
using HarmonyLib;
|
|
using Unity.Collections;
|
|
|
|
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)
|
|
{
|
|
|
|
}
|
|
|
|
public void WriteFrameFromNativeBuffer(NativeArray<byte> buffer)
|
|
{
|
|
|
|
}
|
|
|
|
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)
|
|
{
|
|
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
} |