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();
+ }
+ }
+}