using System; using FFmpeg.AutoGen; using Unity.Collections; using Unity.Collections.LowLevel.Unsafe; namespace ADOFAIRenderer.Util.FFmpeg { /// /// FFmpeg의 C 네이티브 API를 사용하여 비디오 파일을 스트리밍 방식으로 디코딩하는 클래스입니다. /// 플레이어 형태가 아니며, 필요할 때 프레임을 순차적으로 읽거나 앞으로 탐색(Seek/Skip)할 수 있습니다. /// public sealed unsafe class FFmpegNativeDecoder : 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 AVPixelFormat _dstPixFmt = 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 AVPixelFormat PixelFormat => _pCodecContext != null ? _pCodecContext->pix_fmt : AVPixelFormat.AV_PIX_FMT_NONE; public FFmpegNativeDecoder(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, AVPixelFormat dstFormat) { if (_pSwsContext != null && _srcPixFmt == srcFormat && _srcWidth == srcWidth && _srcHeight == srcHeight && _dstPixFmt == dstFormat) { return; } if (_pSwsContext != null) { ffmpeg.sws_freeContext(_pSwsContext); _pSwsContext = null; } _srcPixFmt = srcFormat; _srcWidth = srcWidth; _srcHeight = srcHeight; _dstPixFmt = dstFormat; _pSwsContext = ffmpeg.sws_getContext( srcWidth, srcHeight, srcFormat, Width, Height, dstFormat, (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, AVPixelFormat.AV_PIX_FMT_RGBA); 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, AVPixelFormat.AV_PIX_FMT_RGBA); 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); } /// /// 다음 비디오 프레임을 읽어 Y, U, V 채널 데이터를 각각 복사합니다. /// 비디오 원본이 YUV420p 계열(YUV420P, YUVJ420P)이면 CPU 컬러 변환(sws_scale)을 우회하고 메모리를 다이렉트로 복사하여 속도가 매우 빠릅니다. /// 그 외 포맷일 경우 자동으로 YUV420p로 변환 후 복사합니다. /// public bool TryReadYUV420PFrame( byte* yBuffer, int yStride, byte* uBuffer, int uStride, byte* vBuffer, int vStride, out double timestamp) { timestamp = 0; lock (_lock) { if (!_isOpened || _disposed) return false; AVFrame* frame = null; if (_hasCachedFrame) { frame = _pCachedFrame; _hasCachedFrame = false; } else { while (true) { int ret = ffmpeg.avcodec_receive_frame(_pCodecContext, _pDecodedFrame); if (ret == 0) { frame = _pDecodedFrame; break; } 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)}"); } } } if (frame == null) return false; timestamp = GetTimestampAndUpdateIndex(frame); if (frame->format != (int)AVPixelFormat.AV_PIX_FMT_YUV420P && frame->format != (int)AVPixelFormat.AV_PIX_FMT_YUVJ420P) { InitOrUpdateSwsContext((AVPixelFormat)frame->format, frame->width, frame->height, AVPixelFormat.AV_PIX_FMT_YUV420P); _pConvertedFrame->data[0] = yBuffer; _pConvertedFrame->data[1] = uBuffer; _pConvertedFrame->data[2] = vBuffer; _pConvertedFrame->linesize[0] = yStride; _pConvertedFrame->linesize[1] = uStride; _pConvertedFrame->linesize[2] = vStride; ffmpeg.sws_scale( _pSwsContext, frame->data, frame->linesize, 0, frame->height, _pConvertedFrame->data, _pConvertedFrame->linesize ); } else { int w = frame->width; int h = frame->height; int uvW = w / 2; int uvH = h / 2; // Y plane 복사 for (int y = 0; y < h; y++) { Buffer.MemoryCopy( frame->data[0] + y * frame->linesize[0], yBuffer + y * yStride, w, w ); } // U plane 복사 for (int y = 0; y < uvH; y++) { Buffer.MemoryCopy( frame->data[1] + y * frame->linesize[1], uBuffer + y * uStride, uvW, uvW ); } // V plane 복사 for (int y = 0; y < uvH; y++) { Buffer.MemoryCopy( frame->data[2] + y * frame->linesize[2], vBuffer + y * vStride, uvW, uvW ); } } ffmpeg.av_frame_unref(frame); return true; } } public bool TryReadYUV420PFrame( NativeArray yBuffer, NativeArray uBuffer, NativeArray vBuffer, out double timestamp) { int w = Width; int h = Height; int uvW = w / 2; int uvH = h / 2; if (yBuffer.Length < w * h || uBuffer.Length < uvW * uvH || vBuffer.Length < uvW * uvH) { throw new ArgumentException("버퍼 크기가 비디오 YUV 평면 해상도보다 작습니다."); } return TryReadYUV420PFrame( (byte*)yBuffer.GetUnsafePtr(), w, (byte*)uBuffer.GetUnsafePtr(), uvW, (byte*)vBuffer.GetUnsafePtr(), uvW, out timestamp ); } /// /// 다음 비디오 프레임을 읽어 NV12 형식의 Y, UV 채널 데이터를 각각 복사합니다. /// 비디오 원본이 NV12 계열이면 CPU 컬러 변환(sws_scale)을 우회하고 메모리를 다이렉트로 복사하여 속도가 매우 빠릅니다. /// 그 외 포맷일 경우 자동으로 NV12로 변환 후 복사합니다. /// public bool TryReadNV12Frame( byte* yBuffer, int yStride, byte* uvBuffer, int uvStride, out double timestamp) { timestamp = 0; lock (_lock) { if (!_isOpened || _disposed) return false; AVFrame* frame = null; if (_hasCachedFrame) { frame = _pCachedFrame; _hasCachedFrame = false; } else { while (true) { int ret = ffmpeg.avcodec_receive_frame(_pCodecContext, _pDecodedFrame); if (ret == 0) { frame = _pDecodedFrame; break; } 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)}"); } } } if (frame == null) return false; timestamp = GetTimestampAndUpdateIndex(frame); if (frame->format != (int)AVPixelFormat.AV_PIX_FMT_NV12) { InitOrUpdateSwsContext((AVPixelFormat)frame->format, frame->width, frame->height, AVPixelFormat.AV_PIX_FMT_NV12); _pConvertedFrame->data[0] = yBuffer; _pConvertedFrame->data[1] = uvBuffer; _pConvertedFrame->linesize[0] = yStride; _pConvertedFrame->linesize[1] = uvStride; ffmpeg.sws_scale( _pSwsContext, frame->data, frame->linesize, 0, frame->height, _pConvertedFrame->data, _pConvertedFrame->linesize ); } else { int w = frame->width; int h = frame->height; int uvH = h / 2; // Y plane 복사 for (int y = 0; y < h; y++) { Buffer.MemoryCopy( frame->data[0] + y * frame->linesize[0], yBuffer + y * yStride, w, w ); } // UV plane 복사 for (int y = 0; y < uvH; y++) { Buffer.MemoryCopy( frame->data[1] + y * frame->linesize[1], uvBuffer + y * uvStride, w, w ); } } ffmpeg.av_frame_unref(frame); return true; } } public bool TryReadNV12Frame( NativeArray yBuffer, NativeArray uvBuffer, out double timestamp) { int w = Width; int h = Height; int uvH = h / 2; if (yBuffer.Length < w * h || uvBuffer.Length < w * uvH) { throw new ArgumentException("버퍼 크기가 비디오 NV12 평면 해상도보다 작습니다."); } return TryReadNV12Frame( (byte*)yBuffer.GetUnsafePtr(), w, (byte*)uvBuffer.GetUnsafePtr(), w, 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); } } ~FFmpegNativeDecoder() { Dispose(); } } }