From 77668b9d61bbeafa2a7a5f6e3059167d02680d0c3087ef60ef795ebb9f62100d Mon Sep 17 00:00:00 2001 From: copecone Date: Sat, 20 Jun 2026 06:45:08 +0900 Subject: [PATCH] Add FFmpegVideoDecoder --- Util/FFmpeg/FFmpegVideoDecoder.cs | 582 ++++++++++++++++++++++++++++++ 1 file changed, 582 insertions(+) create mode 100644 Util/FFmpeg/FFmpegVideoDecoder.cs diff --git a/Util/FFmpeg/FFmpegVideoDecoder.cs b/Util/FFmpeg/FFmpegVideoDecoder.cs new file mode 100644 index 0000000..efa699e --- /dev/null +++ b/Util/FFmpeg/FFmpegVideoDecoder.cs @@ -0,0 +1,582 @@ +using System; +using FFmpeg.AutoGen; +using MelonLoader; +using Unity.Collections; +using Unity.Collections.LowLevel.Unsafe; + +namespace ADOFAIRenderer.Util.FFmpeg +{ + /// + /// FFmpeg의 C 네이티브 API를 사용하여 비디오 파일을 스트리밍 방식으로 디코딩하는 클래스입니다. + /// 플레이어 형태가 아니며, 필요할 때 프레임을 순차적으로 읽거나 앞으로 탐색(Seek/Skip)할 수 있습니다. + /// + public sealed unsafe class FFmpegVideoDecoder : IDisposable + { + private AVFormatContext* _pFormatContext = null; + private AVCodecContext* _pCodecContext = null; + private AVFrame* _pDecodedFrame = null; + private AVFrame* _pConvertedFrame = null; + private SwsContext* _pSwsContext = null; + private AVPacket* _pPacket = null; + + private int _videoStreamIndex = -1; + private long _currentFrameIndex; + private AVRational _timeBase; + private bool _isOpened; + private bool _disposed; + private bool _isDraining; + private readonly object _lock = new(); + + // 탐색(Seek) 후 매칭되는 프레임을 보관하기 위한 캐시 프레임 + private AVFrame* _pCachedFrame = null; + private bool _hasCachedFrame; + + // SwsContext 갱신 감지용 필드 + private AVPixelFormat _srcPixFmt = AVPixelFormat.AV_PIX_FMT_NONE; + private int _srcWidth; + private int _srcHeight; + + /// 비디오 가로 해상도 (픽셀). + public int Width { get; private set; } + + /// 비디오 세로 해상도 (픽셀). + public int Height { get; private set; } + + /// 초당 프레임 수. + public double FPS { get; private set; } + + /// 전체 재생 시간 (초). + public double Duration { get; private set; } + + /// 전체 프레임 수 (추정치). + public long TotalFrames { get; private set; } + + /// 현재 읽은 프레임의 인덱스. + public long CurrentFrameIndex => _currentFrameIndex; + + /// 현재 재생 시간 (초). + public double CurrentTime => _currentFrameIndex / FPS; + + public FFmpegVideoDecoder(string videoPath) + { + Open(videoPath); + } + + private void Open(string videoPath) + { + lock (_lock) + { + if (_isOpened || _disposed) return; + + try + { + fixed (AVFormatContext** ppFormatCtx = &_pFormatContext) + { + int ret = ffmpeg.avformat_open_input(ppFormatCtx, videoPath, null, null); + if (ret < 0) + throw new Exception($"비디오 파일을 열 수 없습니다: {videoPath} ({GetFFmpegErrorString(ret)})"); + } + + if (ffmpeg.avformat_find_stream_info(_pFormatContext, null) < 0) + throw new Exception("스트림 정보를 읽을 수 없습니다."); + + _videoStreamIndex = -1; + for (int i = 0; i < _pFormatContext->nb_streams; i++) + { + if (_pFormatContext->streams[i]->codecpar->codec_type == AVMediaType.AVMEDIA_TYPE_VIDEO) + { + _videoStreamIndex = i; + break; + } + } + + if (_videoStreamIndex == -1) + throw new Exception("비디오 스트림을 찾을 수 없습니다."); + + var stream = _pFormatContext->streams[_videoStreamIndex]; + _timeBase = stream->time_base; + + // FPS 계산 + if (stream->r_frame_rate.num > 0 && stream->r_frame_rate.den > 0) + { + FPS = ffmpeg.av_q2d(stream->r_frame_rate); + } + else if (stream->avg_frame_rate.num > 0 && stream->avg_frame_rate.den > 0) + { + FPS = ffmpeg.av_q2d(stream->avg_frame_rate); + } + else + { + FPS = 30.0; // 기본값 폴백 + } + + var codecId = stream->codecpar->codec_id; + var decoder = ffmpeg.avcodec_find_decoder(codecId); + if (decoder == null) + throw new Exception($"지원하지 않는 비디오 코덱입니다: {codecId}"); + + _pCodecContext = ffmpeg.avcodec_alloc_context3(decoder); + if (_pCodecContext == null) + throw new Exception("코덱 컨텍스트를 할당할 수 없습니다."); + + if (ffmpeg.avcodec_parameters_to_context(_pCodecContext, stream->codecpar) < 0) + throw new Exception("코덱 파라미터를 복사할 수 없습니다."); + + // 디코딩 성능 향상을 위한 멀티스레딩 설정 (자동) + _pCodecContext->thread_count = 0; + _pCodecContext->thread_type = ffmpeg.FF_THREAD_FRAME; + + if (ffmpeg.avcodec_open2(_pCodecContext, decoder, null) < 0) + throw new Exception("코덱을 열 수 없습니다."); + + Width = _pCodecContext->width; + Height = _pCodecContext->height; + + // 총 프레임 수 및 재생 시간 계산 + if (stream->nb_frames > 0) + { + TotalFrames = stream->nb_frames; + } + else if (stream->duration > 0) + { + TotalFrames = (long)Math.Round(stream->duration * ffmpeg.av_q2d(stream->time_base) * FPS); + } + else if (_pFormatContext->duration > 0) + { + TotalFrames = (long)Math.Round((_pFormatContext->duration / (double)ffmpeg.AV_TIME_BASE) * FPS); + } + else + { + TotalFrames = 0; + } + + Duration = _pFormatContext->duration > 0 + ? _pFormatContext->duration / (double)ffmpeg.AV_TIME_BASE + : (stream->duration > 0 ? stream->duration * ffmpeg.av_q2d(stream->time_base) : 0); + + _pDecodedFrame = ffmpeg.av_frame_alloc(); + _pConvertedFrame = ffmpeg.av_frame_alloc(); + _pCachedFrame = ffmpeg.av_frame_alloc(); + _pPacket = ffmpeg.av_packet_alloc(); + + _currentFrameIndex = 0; + _isOpened = true; + _isDraining = false; + } + catch (Exception) + { + Dispose(); + throw; + } + } + } + + private bool ReadAndSendPacket() + { + while (true) + { + ffmpeg.av_packet_unref(_pPacket); + int ret = ffmpeg.av_read_frame(_pFormatContext, _pPacket); + if (ret < 0) return false; // EOF 또는 에러 + + if (_pPacket->stream_index == _videoStreamIndex) + { + ret = ffmpeg.avcodec_send_packet(_pCodecContext, _pPacket); + ffmpeg.av_packet_unref(_pPacket); + if (ret < 0) + { + throw new Exception($"디코더에 패킷을 전송하는 중 오류 발생: {GetFFmpegErrorString(ret)}"); + } + return true; + } + } + } + + private void InitOrUpdateSwsContext(AVPixelFormat srcFormat, int srcWidth, int srcHeight) + { + if (_pSwsContext != null && _srcPixFmt == srcFormat && _srcWidth == srcWidth && _srcHeight == srcHeight) + { + return; + } + + if (_pSwsContext != null) + { + ffmpeg.sws_freeContext(_pSwsContext); + _pSwsContext = null; + } + + _srcPixFmt = srcFormat; + _srcWidth = srcWidth; + _srcHeight = srcHeight; + + // Unity 표준인 RGBA32(AV_PIX_FMT_RGBA) 포맷으로 변환하도록 설정 + _pSwsContext = ffmpeg.sws_getContext( + srcWidth, srcHeight, srcFormat, + Width, Height, AVPixelFormat.AV_PIX_FMT_RGBA, + (int)SwsFlags.SWS_BILINEAR, null, null, null + ); + + if (_pSwsContext == null) + { + throw new Exception("SwsContext를 초기화할 수 없습니다."); + } + } + + private double GetTimestampAndUpdateIndex(AVFrame* frame) + { + long ptsValue = frame->best_effort_timestamp; + if (ptsValue == ffmpeg.AV_NOPTS_VALUE) + { + ptsValue = frame->pts; + } + + double timestamp; + if (ptsValue == ffmpeg.AV_NOPTS_VALUE) + { + timestamp = _currentFrameIndex / FPS; + _currentFrameIndex++; + } + else + { + timestamp = ptsValue * ffmpeg.av_q2d(_timeBase); + _currentFrameIndex = (long)Math.Round(timestamp * FPS); + } + return timestamp; + } + + /// + /// 다음 비디오 프레임을 읽고 RGBA 형태로 복사합니다. + /// + /// RGBA 데이터를 저장할 버퍼의 포인터 + /// 출력 버퍼의 가로 한 행의 바이트 크기 (일반적으로 Width * 4) + /// Unity 텍스처 시스템에 맞게 상하 flip을 적용할지 여부 + /// 출력된 프레임의 타임스탬프 (초) + /// 프레임을 성공적으로 디코딩하여 복사했으면 true, 재생이 끝났거나 실패했으면 false + public bool TryReadFrame(byte* outBuffer, int outStride, bool vflip, out double timestamp) + { + timestamp = 0; + lock (_lock) + { + if (!_isOpened || _disposed) return false; + + if (_hasCachedFrame) + { + timestamp = GetTimestampAndUpdateIndex(_pCachedFrame); + InitOrUpdateSwsContext(_pCodecContext->pix_fmt, _pCachedFrame->width, _pCachedFrame->height); + + if (vflip) + { + _pConvertedFrame->data[0] = outBuffer + outStride * (Height - 1); + _pConvertedFrame->linesize[0] = -outStride; + } + else + { + _pConvertedFrame->data[0] = outBuffer; + _pConvertedFrame->linesize[0] = outStride; + } + + ffmpeg.sws_scale( + _pSwsContext, + _pCachedFrame->data, _pCachedFrame->linesize, + 0, _pCachedFrame->height, + _pConvertedFrame->data, _pConvertedFrame->linesize + ); + + ffmpeg.av_frame_unref(_pCachedFrame); + _hasCachedFrame = false; + return true; + } + + while (true) + { + int ret = ffmpeg.avcodec_receive_frame(_pCodecContext, _pDecodedFrame); + if (ret == 0) + { + timestamp = GetTimestampAndUpdateIndex(_pDecodedFrame); + InitOrUpdateSwsContext(_pCodecContext->pix_fmt, _pDecodedFrame->width, _pDecodedFrame->height); + + if (vflip) + { + _pConvertedFrame->data[0] = outBuffer + outStride * (Height - 1); + _pConvertedFrame->linesize[0] = -outStride; + } + else + { + _pConvertedFrame->data[0] = outBuffer; + _pConvertedFrame->linesize[0] = outStride; + } + + ffmpeg.sws_scale( + _pSwsContext, + _pDecodedFrame->data, _pDecodedFrame->linesize, + 0, _pDecodedFrame->height, + _pConvertedFrame->data, _pConvertedFrame->linesize + ); + + ffmpeg.av_frame_unref(_pDecodedFrame); + return true; + } + + if (ret == ffmpeg.AVERROR(ffmpeg.EAGAIN)) + { + if (_isDraining) return false; + + if (!ReadAndSendPacket()) + { + _isDraining = true; + ffmpeg.avcodec_send_packet(_pCodecContext, null); + } + } + else if (ret == ffmpeg.AVERROR_EOF) + { + return false; + } + else + { + throw new Exception($"디코딩 중 오류 발생: {GetFFmpegErrorString(ret)}"); + } + } + } + } + + public bool TryReadFrame(IntPtr outBuffer, int outStride, bool vflip, out double timestamp) + { + return TryReadFrame((byte*)outBuffer.ToPointer(), outStride, vflip, out timestamp); + } + + public bool TryReadFrame(NativeArray outBuffer, bool vflip, out double timestamp) + { + if (outBuffer.Length < Width * Height * 4) + { + throw new ArgumentException("출력 버퍼의 크기가 비디오 해상도보다 작습니다.", nameof(outBuffer)); + } + int stride = Width * 4; + return TryReadFrame((byte*)outBuffer.GetUnsafePtr(), stride, vflip, out timestamp); + } + + /// + /// 지정한 개수만큼 프레임을 디코딩 후 바로 버립니다. (빠른 전방 탐색용) + /// + /// 버릴 프레임 개수 + /// 실제로 건너뛴 프레임 개수 + public int SkipFrames(int count) + { + lock (_lock) + { + if (!_isOpened || _disposed) return 0; + if (count <= 0) return 0; + + int skipped = 0; + if (_hasCachedFrame) + { + ffmpeg.av_frame_unref(_pCachedFrame); + _hasCachedFrame = false; + skipped++; + } + + while (skipped < count) + { + int ret = ffmpeg.avcodec_receive_frame(_pCodecContext, _pDecodedFrame); + if (ret == 0) + { + GetTimestampAndUpdateIndex(_pDecodedFrame); + ffmpeg.av_frame_unref(_pDecodedFrame); + skipped++; + continue; + } + + if (ret == ffmpeg.AVERROR(ffmpeg.EAGAIN)) + { + if (_isDraining) break; + + if (!ReadAndSendPacket()) + { + _isDraining = true; + ffmpeg.avcodec_send_packet(_pCodecContext, null); + } + } + else if (ret == ffmpeg.AVERROR_EOF) + { + break; + } + else + { + throw new Exception($"프레임 건너뛰기 중 오류 발생: {GetFFmpegErrorString(ret)}"); + } + } + return skipped; + } + } + + /// + /// 특정 재생 시간(초)으로 탐색(Seek)합니다. + /// + /// 탐색할 시간 (초) + public void SeekToTime(double timeSeconds) + { + lock (_lock) + { + if (!_isOpened || _disposed) return; + + if (timeSeconds < 0) timeSeconds = 0; + double targetFrameIndex = timeSeconds * FPS; + + // 이미 해당 인덱스이고 매우 인접한 미래라면 seek 대신 skip 처리 (I-프레임 찾기 오버헤드 방지) + bool useSkip = targetFrameIndex >= _currentFrameIndex && (targetFrameIndex - _currentFrameIndex) < 60; + if (useSkip) + { + SkipFrames((int)(targetFrameIndex - _currentFrameIndex)); + return; + } + + if (_hasCachedFrame) + { + ffmpeg.av_frame_unref(_pCachedFrame); + _hasCachedFrame = false; + } + + long targetPts = (long)Math.Round(timeSeconds / ffmpeg.av_q2d(_timeBase)); + int ret = ffmpeg.av_seek_frame(_pFormatContext, _videoStreamIndex, targetPts, ffmpeg.AVSEEK_FLAG_BACKWARD); + if (ret < 0) + { + // 실패 시 파일 처음부터 탐색 시도 + ret = ffmpeg.av_seek_frame(_pFormatContext, _videoStreamIndex, 0, ffmpeg.AVSEEK_FLAG_BACKWARD); + if (ret < 0) + { + throw new Exception($"Seek 실패: {GetFFmpegErrorString(ret)}"); + } + } + + // 디코더 내부 캐시 초기화 + ffmpeg.avcodec_flush_buffers(_pCodecContext); + _isDraining = false; + + // 지정된 시간 이후의 첫 번째 프레임을 찾을 때까지 디코딩을 진행하며 버립니다. + while (true) + { + ret = ffmpeg.avcodec_receive_frame(_pCodecContext, _pDecodedFrame); + if (ret == 0) + { + double currentPts = GetTimestampAndUpdateIndex(_pDecodedFrame); + + // 플로팅 포인트 오차 감안하여 하프 프레임 제외한 시간부터 체크 + if (currentPts >= timeSeconds - (0.5 / FPS)) + { + // 매칭되는 첫 프레임을 캐시에 임시 저장하여 다음 TryReadFrame에서 소모하도록 유도 + ffmpeg.av_frame_move_ref(_pCachedFrame, _pDecodedFrame); + _hasCachedFrame = true; + break; + } + + ffmpeg.av_frame_unref(_pDecodedFrame); + continue; + } + + if (ret == ffmpeg.AVERROR(ffmpeg.EAGAIN)) + { + if (_isDraining) break; + + if (!ReadAndSendPacket()) + { + _isDraining = true; + ffmpeg.avcodec_send_packet(_pCodecContext, null); + } + } + else if (ret == ffmpeg.AVERROR_EOF) + { + break; + } + else + { + throw new Exception($"Seek 디코딩 중 오류 발생: {GetFFmpegErrorString(ret)}"); + } + } + } + } + + /// + /// 특정 프레임 인덱스로 탐색(Seek)합니다. + /// + /// 탐색할 프레임 인덱스 + public void SeekToFrame(long frameIndex) + { + SeekToTime(frameIndex / FPS); + } + + 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})"; + } + + public void Dispose() + { + lock (_lock) + { + if (_disposed) return; + _disposed = true; + + if (_pSwsContext != null) + { + ffmpeg.sws_freeContext(_pSwsContext); + _pSwsContext = null; + } + + if (_pDecodedFrame != null) + { + var pFrame = _pDecodedFrame; + ffmpeg.av_frame_free(&pFrame); + _pDecodedFrame = null; + } + + if (_pCachedFrame != null) + { + var pFrame = _pCachedFrame; + ffmpeg.av_frame_free(&pFrame); + _pCachedFrame = null; + } + + if (_pConvertedFrame != null) + { + var pFrame = _pConvertedFrame; + ffmpeg.av_frame_free(&pFrame); + _pConvertedFrame = null; + } + + if (_pPacket != null) + { + var pPacket = _pPacket; + ffmpeg.av_packet_free(&pPacket); + _pPacket = null; + } + + if (_pCodecContext != null) + { + var pCodecCtx = _pCodecContext; + ffmpeg.avcodec_free_context(&pCodecCtx); + _pCodecContext = null; + } + + if (_pFormatContext != null) + { + var pFormatCtx = _pFormatContext; + ffmpeg.avformat_close_input(&pFormatCtx); + _pFormatContext = null; + } + + _isOpened = false; + GC.SuppressFinalize(this); + } + } + + ~FFmpegVideoDecoder() + { + Dispose(); + } + } +}