Media Foundation으로 MP4 동영상의 지정 시각에서 정지 이미지를 뽑는 방법 - .cpp에 그대로 붙일 수 있는 1파일 완결판

· · Media Foundation, C++, Windows 개발, WIC

MP4에서 「12.3초 지점의 1장」을 얻고 싶다는 요건은 꽤 평범하게 있습니다. 썸네일 생성, 검사 로그, 감시 영상의 대표 프레임, 장비 로그의 증거 등입니다.

다만 Media Foundation에서는 여기가 살짝 자연스럽지 않습니다. SetCurrentPosition 뒤에 ReadSample을 1번 부르면 끝나 보이지만, 실제로는 key frame, timestamp, stride, 이미지 상하 방향, RGB32의 4바이트째 등이 얽힙니다. 거칠게 진행하면 시각이 약간 어긋나거나, 이미지가 상하가 뒤집히거나, PNG가 묘하게 투명해지는 수수하게 싫은 사고가 일어납니다.

Media Foundation의 전체상 자체는 이전에 쓴 Media Foundation이란 무엇인가 - COM과 Windows 미디어 API의 얼굴이 보이는 이유 도 참고가 됩니다. 이번에는 거기서 한 층 내려와 MP4에서 1장 뽑는 것에만 좁힙니다.

이 글에서는 IMFSourceReader를 사용해 MP4에서 지정 시각에 가장 가까운 정지 이미지를 1장 꺼내 PNG로 저장하는 부분까지를, 실무에서 밟기 쉬운 함정 포함으로 정리합니다. 게다가 끝에는 Visual Studio C++ 콘솔 앱 프로젝트의 .cpp에 그대로 붙이기 쉬운 1파일 완결 코드 를 뒀습니다. 글 속 잘린 코드는 없고, 마지막 1블록만 가져가면 도는 구성입니다.

1. 먼저 결론

먼저 결론만 정리하면 이렇습니다.

  • MP4에서 1장 뽑는다면 이번에는 Media Session보다 Source Reader 쪽이 입구로서 자연스럽습니다
  • IMFSourceReader::SetCurrentPosition은 exact seek를 보증하지 않습니다. 보통 target보다 조금 앞, 특히 key frame 쪽으로 쏠리므로, 그 뒤 ReadSample을 진행해 목표 시각 전후를 비교할 필요가 있습니다
  • ReadSample은 성공이어도 pSample == nullptr일 수 있습니다. HRESULT뿐 아니라 flagspSample도 봅니다
  • 출력 미디어 타입을 MFVideoFormat_RGB32로 맞추면 저장하기 쉽습니다
  • 다만 RGB32의 4바이트째는 alpha라고는 할 수 없으므로 그대로 PNG로 쓰면 투명 이미지가 될 수 있습니다. 저장 전에 0xFF를 넣어 불투명으로 해 두는 게 안전합니다
  • 행별 stride와 top-down / bottom-up을 거칠게 다루면 이미지가 무너지므로, 꺼낸 sample은 일단 top-down의 연속 BGRA 로 다듬은 뒤 PNG로 넘기는 게 편합니다

요컨대 seek -> 1번 읽음 -> 저장은 살짝 거칠고, seek -> timestamp를 보면서 전후 비교 -> stride를 의식해 복사 -> PNG 저장 정도까지 하면 꽤 안정적입니다.

2. 이 글의 전제

이번에는 다음 전제로 진행합니다.

  • 입력은 로컬 MP4 파일
  • 원하는 건 1장의 정지 이미지
  • 「지정 시각 정확히」가 아니라 「지정 시각에 가장 가까운 프레임」을 반환
  • 구현은 동기 모드의 IMFSourceReader
  • 저장 형식은 WIC를 쓴 PNG
  • 외부 라이브러리를 쓰지 않고 Windows 표준 API만으로 완결
  • 중간에 해상도가 바뀌지 않는 일반적인 MP4를 전제

재생, 음성 동기화, 시크바, UI 연동까지 할 거라면 다른 설계도 있지만, 1 프레임 얻고 싶다 는 용도라면 이쪽이 꽤 명료합니다.

3. 먼저 볼 정리표

3.1. 처리 흐름

할 일 쓰는 API 역할
MP4 열기 MFCreateSourceReaderFromURL 파일에서 미디어 소스 생성
동영상만 고르기 SetStreamSelection 음성을 읽지 않도록
RGB32로 변환 SetCurrentMediaType + MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING 저장하기 쉬운 uncompressed frame을 얻는다
지정 시각으로 이동 SetCurrentPosition 100ns 단위로 seek
프레임 읽기 ReadSample 디코드된 sample을 1장씩 취득
직전 / 직후 비교 sample timestamp 지정 시각에 가장 가까운 1장을 결정
PNG로 저장 WIC 이미지 파일로 쓰기

3.2. 이번 판정 룰

「지정 시각의 정지 이미지」라고 해도 동영상은 연속량이 아니라 이산 프레임입니다. 그러니 구현에서는 어떤 룰로 1장을 고를지 먼저 정하는 편이 편합니다.

이번에는 다음 룰로 합니다.

  • seek 후 ReadSample을 진행
  • timestamp < target의 마지막 sample을 보유
  • timestamp >= target의 최초 sample이 오면 직전 sample과 현재 sample의 차이를 비교
  • target에 더 가까운 쪽을 채용

이걸로 「target 이후의 최초 1장」이 아니라 target에 가장 가까운 1장 을 얻기 쉬워집니다.

3.3. 처리 이미지

처리의 머리부터 끝까지는 대략 input.mp4 -> Source Reader 생성 -> RGB32 요구 -> seek -> ReadSample 반복 -> target 전후 비교 -> top-down BGRA로 다시 채움 -> WIC로 PNG 저장 입니다.

겉보기는 단순하지만 seek 정밀도, sample의 null, stride, 4바이트째 취급 각각에 작은 함정이 있습니다. 여기를 밟지 않으면 꽤 자연스럽게 쓸 수 있습니다.

4. 먼저 잡아 둘 함정

4.1. SetCurrentPosition은 exact seek가 아니다

Microsoft Learn의 IMFSourceReader::SetCurrentPosition 에도 있듯 이는 exact seeking을 보증하지 않습니다. 동영상에서는 보통 지정 위치보다 조금 앞, 특히 key frame 쪽으로 쏠립니다. 게다가 그 뒤 ReadSample 을 진행해 목표 위치까지 전진하는 전제입니다.

그래서 다음 구현은 꽤 위태롭습니다.

  • SetCurrentPosition(target)
  • ReadSample을 1번
  • 그 frame을 저장

GOP가 긴 동영상에서는 이걸로 꽤 평범하게 어긋납니다. 여기가 수수한 진창입니다.

4.2. ReadSample은 성공이어도 pSample == nullptr일 수 있다

ReadSampleS_OK여도 ppSampleNULL일 때가 있습니다. 끝이라면 MF_SOURCE_READERF_ENDOFSTREAM, 스트림 갭이라면 MF_SOURCE_READERF_STREAMTICK 등의 flag가 돌아옵니다.

그래서 HRESULT만 보고 pSample을 즉시 참조하는 건 위험합니다. HRESULT, flags, pSample을 3점 세트로 보는 정도가 안전합니다.

4.3. stride와 상하 방향을 거칠게 다루면 이미지가 무너진다

이미지 버퍼는 width * bytesPerPixel로 일직선으로 채워져 있다고는 할 수 없습니다. 행 끝에 padding이 들어가는 경우도 있고, RGB 계는 bottom-up일 수도 있습니다. Microsoft Learn의 Image StrideUncompressed Video Buffers 에서도 이 점은 상당히 분명히 쓰여 있습니다.

특히 중요한 건 다음 2점입니다.

  • IMF2DBuffer::Lock2Dscan line 0의 선두 포인터실제 stride 를 돌려준다
  • bottom-up 이미지에서는 stride가 음수가 될 수 있다

이번에는 Microsoft Learn의 helper 사고방식을 가져와 최종적으로 top-down의 연속 BGRA 버퍼 에 다시 채운 뒤 PNG로 넘깁니다. 여기를 먼저 다듬어 두면 저장 측이 꽤 단순해집니다.

4.4. MFVideoFormat_RGB32의 4바이트째를 alpha로 결정 짓지 않기

MFVideoFormat_RGB32는 이름의 공기에 반해 PNG에 그대로 넘길 수 있는 「예쁜 RGBA」가 아닙니다. Windows의 32bit RGB는 byte 0, 1, 2가 B, G, R이고 byte 3은 alpha일 수도 ignore일 수도 있습니다. ARGB32가 아닌 점이 중요합니다.

여기를 GUID_WICPixelFormat32bppBGRA라 생각하고 그대로 저장하면 4바이트째에 0이 들어가 이미지가 묘하게 투명해질 수 있습니다. 이번에는 저장 전에 alpha를 0xFF로 채워 완전 불투명으로 하는 방침으로 합니다.

5. 구현의 흐름

5.1. Source Reader를 동기 모드로 생성

이번에는 1장만 얻으면 되므로 비동기 callback이 아니라 동기 ReadSample로 합니다. 동기 모드에서는 ReadSample이 다음 sample까지 block하지만, 단발 정지 이미지 추출이라면 구현이 꽤 자연스럽습니다.

Reader 생성 시에는 다음을 합니다.

  • MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING = TRUE
  • 모든 stream을 일단 off
  • MF_SOURCE_READER_FIRST_VIDEO_STREAM만 on
  • 출력 type을 MFMediaType_Video / MFVideoFormat_RGB32로 설정

이걸로 뒷단은 「RGB32 프레임을 받는」 전제로 쓰기 쉬워집니다.

5.2. seek 후 timestamp를 보면서 채운다

SetCurrentPosition 후에 바로 저장하지 않습니다. ReadSample로 sample을 읽으며 target보다 앞의 마지막 1장과 target을 넘은 최초 1장을 비교합니다.

이 한 수고로 seek의 거칢을 꽤 흡수할 수 있습니다.

5.3. sample에서 top-down BGRA로 정형

꺼낸 sample은 그대로 PNG로 쓰지 말고, 일단 top-down의 BGRA 버퍼로 다시 채웁니다.

  • ConvertToContiguousBuffer로 1개의 buffer로 만듭니다
  • BufferLock helper로 scan line 0과 actual stride를 얻습니다
  • 행별로 top-down buffer로 복사합니다
  • alpha를 0xFF로 합니다

이걸로 저장 측은 「그냥 32bpp BGRA 이미지」로 다룰 수 있습니다.

5.4. PNG 저장은 WIC에 맡긴다

저장은 WIC의 IWICBitmapEncoder / IWICBitmapFrameEncode를 씁니다. Media Foundation에서 프레임을 얻고 WIC로 이미지화한다는 분담입니다. 여기는 Windows 표준 API만으로 완결됩니다.

6. 실무에서의 체크리스트

항목 볼 것 놓치면 일어나기 쉬운 것
seek 정밀도 SetCurrentPosition 직후 1번으로 결정하지 말 것 지정 시각보다 꽤 앞의 frame을 저장
sample의 NULL HRESULT, flags, pSample을 전부 볼 것 끝이나 stream tick에서 null dereference
stride actual stride와 상하 방향을 흡수 이미지가 무너지고 상하가 뒤집힘
RGB32의 4 byte째 alpha를 0xFF 투명 PNG가 됨
시각 범위 0 <= target < duration을 지킴 끝 부근에서 의도하지 않은 거동
연속 추출 Reader를 다시 만들지 말고 seek를 반복 쓸데없이 느림
copy 횟수 대량 처리에서는 ConvertToContiguousBuffer 비용을 의식 CPU와 메모리 대역을 여분 사용
포맷 변화 중간에 해상도가 바뀌는 특수 동영상은 다른 설계로 폭·높이 전제가 깨짐

7. 빌드와 실행 메모

이 글 끝의 코드는 Visual Studio C++ 콘솔 앱에 1개의 .cpp로 추가하기 쉬운 형태 로 되어 있습니다.

잡아 두면 편한 점은 다음입니다.

  • #pragma comment(lib, ...)을 넣어 두었으므로 추가 링커 설정은 기본적으로 불필요
  • wmain을 쓰므로 커맨드라인 인자는 Unicode 그대로 다룰 수 있음
  • 기본 Console App 템플릿에 pch.hstdafx.h가 있는 경우도 붙이기 쉽도록, 코드 맨 앞에서 __has_include를 사용해 줍도록 되어 있음
  • 그래도 프로젝트 쪽에서 독자 프리컴파일 헤더를 강제한다면 이 .cpp만 「프리컴파일 헤더 사용 안 함」으로 하면 통과
  • 실행 구성은 x64 추천

사용법은 ExtractFrameFromMp4.exe <input.mp4> <seconds> <output.png> 입니다. 예컨대 ExtractFrameFromMp4.exe C:\work\input.mp4 12.345 C:\work\frame.png 처럼 실행합니다.

8. 정리

Media Foundation으로 MP4에서 지정 시각의 정지 이미지를 꺼낼 때는 SetCurrentPositionReadSample만 보면 살짝 부족합니다. 실제로는,

  • seek는 exact가 아니다
  • frame은 timestamp를 보고 전후 비교하는 게 좋다
  • ReadSample 성공이어도 sample이 없을 수 있다
  • stride와 이미지 방향을 흡수하고 저장한다
  • RGB32의 4바이트째를 alpha로 결정 짓지 않는다

이 정도까지 잡아 두면 꽤 사고가 줄어듭니다.

이번 샘플은 1장을 제대로 뽑는 것에 좁힌 최소 구성으로서는 꽤 쓰기 편할 것입니다. 썸네일 생성, 감시 영상의 대표 frame 저장, 검사 로그의 증거 출력 근처에는 그대로 가져오기 쉬운 구성입니다.

9. 참고 자료

10. .cpp에 그대로 붙일 수 있는 풀 코드

아래 1블록이 그대로 Visual Studio C++ 콘솔 앱 프로젝트로 가져갈 전제의 코드입니다. 커맨드라인 인자는 input.mp4, seconds, output.png 순서입니다. 1파일 완결 구성으로 해 두어 프로젝트에 붙이기 쉬운 형태입니다.

#define NOMINMAX
#if defined(_MSC_VER)
#  if __has_include("pch.h")
#    include "pch.h"
#  elif __has_include("stdafx.h")
#    include "stdafx.h"
#  endif
#endif
#include <windows.h>
#include <mfapi.h>
#include <mfidl.h>
#include <mfreadwrite.h>
#include <mferror.h>
#include <mfobjects.h>
#include <propvarutil.h>
#include <wincodec.h>

#include <cerrno>
#include <cstdio>
#include <cstdlib>
#include <cwchar>
#include <cmath>
#include <cstring>
#include <limits>
#include <vector>

#pragma comment(lib, "mfplat.lib")
#pragma comment(lib, "mfreadwrite.lib")
#pragma comment(lib, "mfuuid.lib")
#pragma comment(lib, "ole32.lib")
#pragma comment(lib, "propsys.lib")
#pragma comment(lib, "windowscodecs.lib")

template <class T>
void SafeRelease(T** pp)
{
    if (pp != nullptr && *pp != nullptr)
    {
        (*pp)->Release();
        *pp = nullptr;
    }
}

class MediaFoundationScope
{
public:
    MediaFoundationScope() : m_comInitialized(false), m_mfStarted(false)
    {
    }

    HRESULT Initialize()
    {
        HRESULT hr = CoInitializeEx(nullptr, COINIT_MULTITHREADED);
        if (hr == RPC_E_CHANGED_MODE)
        {
            return hr;
        }

        if (SUCCEEDED(hr))
        {
            m_comInitialized = true;
        }

        hr = MFStartup(MF_VERSION);
        if (FAILED(hr))
        {
            if (m_comInitialized)
            {
                CoUninitialize();
                m_comInitialized = false;
            }
            return hr;
        }

        m_mfStarted = true;
        return S_OK;
    }

    ~MediaFoundationScope()
    {
        if (m_mfStarted)
        {
            MFShutdown();
        }

        if (m_comInitialized)
        {
            CoUninitialize();
        }
    }

private:
    bool m_comInitialized;
    bool m_mfStarted;
};

HRESULT GetPresentationDuration(IMFSourceReader* pReader, LONGLONG* phnsDuration)
{
    if (pReader == nullptr || phnsDuration == nullptr)
    {
        return E_POINTER;
    }

    PROPVARIANT var;
    PropVariantInit(&var);

    HRESULT hr = pReader->GetPresentationAttribute(
        MF_SOURCE_READER_MEDIASOURCE,
        MF_PD_DURATION,
        &var);

    if (SUCCEEDED(hr))
    {
        hr = PropVariantToInt64(var, phnsDuration);
    }

    PropVariantClear(&var);
    return hr;
}

HRESULT GetDefaultStride(IMFMediaType* pType, LONG* plStride)
{
    if (pType == nullptr || plStride == nullptr)
    {
        return E_POINTER;
    }

    LONG lStride = 0;
    HRESULT hr = pType->GetUINT32(
        MF_MT_DEFAULT_STRIDE,
        reinterpret_cast<UINT32*>(&lStride));

    if (FAILED(hr))
    {
        GUID subtype = GUID_NULL;
        UINT32 width = 0;
        UINT32 height = 0;

        hr = pType->GetGUID(MF_MT_SUBTYPE, &subtype);
        if (FAILED(hr))
        {
            return hr;
        }

        hr = MFGetAttributeSize(pType, MF_MT_FRAME_SIZE, &width, &height);
        if (FAILED(hr))
        {
            return hr;
        }

        hr = MFGetStrideForBitmapInfoHeader(subtype.Data1, width, &lStride);
        if (FAILED(hr))
        {
            return hr;
        }

        (void)pType->SetUINT32(MF_MT_DEFAULT_STRIDE, static_cast<UINT32>(lStride));
    }

    *plStride = lStride;
    return S_OK;
}

class BufferLock
{
public:
    explicit BufferLock(IMFMediaBuffer* pBuffer)
        : m_pBuffer(pBuffer),
          m_p2DBuffer(nullptr),
          m_locked(false)
    {
        if (m_pBuffer != nullptr)
        {
            m_pBuffer->AddRef();
            (void)m_pBuffer->QueryInterface(IID_PPV_ARGS(&m_p2DBuffer));
        }
    }

    ~BufferLock()
    {
        UnlockBuffer();
        SafeRelease(&m_p2DBuffer);
        SafeRelease(&m_pBuffer);
    }

    HRESULT LockBuffer(
        LONG defaultStride,
        DWORD heightInPixels,
        BYTE** ppScanLine0,
        LONG* plStride)
    {
        if (ppScanLine0 == nullptr || plStride == nullptr)
        {
            return E_POINTER;
        }

        *ppScanLine0 = nullptr;
        *plStride = 0;

        HRESULT hr = S_OK;

        if (m_p2DBuffer != nullptr)
        {
            hr = m_p2DBuffer->Lock2D(ppScanLine0, plStride);
        }
        else
        {
            BYTE* pData = nullptr;
            hr = m_pBuffer->Lock(&pData, nullptr, nullptr);
            if (SUCCEEDED(hr))
            {
                *plStride = defaultStride;

                if (defaultStride < 0)
                {
                    const size_t strideAbs = static_cast<size_t>(-defaultStride);
                    *ppScanLine0 = pData + strideAbs * (heightInPixels - 1);
                }
                else
                {
                    *ppScanLine0 = pData;
                }
            }
        }

        m_locked = SUCCEEDED(hr);
        return hr;
    }

    void UnlockBuffer()
    {
        if (!m_locked)
        {
            return;
        }

        if (m_p2DBuffer != nullptr)
        {
            (void)m_p2DBuffer->Unlock2D();
        }
        else if (m_pBuffer != nullptr)
        {
            (void)m_pBuffer->Unlock();
        }

        m_locked = false;
    }

private:
    IMFMediaBuffer* m_pBuffer;
    IMF2DBuffer* m_p2DBuffer;
    bool m_locked;
};

HRESULT CreateConfiguredSourceReader(PCWSTR inputPath, IMFSourceReader** ppReader)
{
    if (inputPath == nullptr || ppReader == nullptr)
    {
        return E_POINTER;
    }

    *ppReader = nullptr;

    IMFAttributes* pAttributes = nullptr;
    IMFSourceReader* pReader = nullptr;
    IMFMediaType* pRequestedType = nullptr;

    HRESULT hr = MFCreateAttributes(&pAttributes, 1);
    if (FAILED(hr))
    {
        goto done;
    }

    hr = pAttributes->SetUINT32(MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING, TRUE);
    if (FAILED(hr))
    {
        goto done;
    }

    hr = MFCreateSourceReaderFromURL(inputPath, pAttributes, &pReader);
    if (FAILED(hr))
    {
        goto done;
    }

    hr = pReader->SetStreamSelection(MF_SOURCE_READER_ALL_STREAMS, FALSE);
    if (FAILED(hr))
    {
        goto done;
    }

    hr = pReader->SetStreamSelection(MF_SOURCE_READER_FIRST_VIDEO_STREAM, TRUE);
    if (FAILED(hr))
    {
        goto done;
    }

    hr = MFCreateMediaType(&pRequestedType);
    if (FAILED(hr))
    {
        goto done;
    }

    hr = pRequestedType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video);
    if (FAILED(hr))
    {
        goto done;
    }

    hr = pRequestedType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_RGB32);
    if (FAILED(hr))
    {
        goto done;
    }

    hr = pReader->SetCurrentMediaType(
        MF_SOURCE_READER_FIRST_VIDEO_STREAM,
        nullptr,
        pRequestedType);
    if (FAILED(hr))
    {
        goto done;
    }

    *ppReader = pReader;
    pReader = nullptr;

done:
    SafeRelease(&pRequestedType);
    SafeRelease(&pReader);
    SafeRelease(&pAttributes);
    return hr;
}

HRESULT SeekSourceReader(IMFSourceReader* pReader, LONGLONG targetHns)
{
    if (pReader == nullptr)
    {
        return E_POINTER;
    }

    PROPVARIANT var;
    PropVariantInit(&var);

    HRESULT hr = InitPropVariantFromInt64(targetHns, &var);
    if (SUCCEEDED(hr))
    {
        hr = pReader->SetCurrentPosition(GUID_NULL, var);
    }

    PropVariantClear(&var);
    return hr;
}

HRESULT ReadNearestVideoSample(
    IMFSourceReader* pReader,
    LONGLONG targetHns,
    IMFSample** ppSample,
    LONGLONG* pChosenTimestampHns)
{
    if (pReader == nullptr || ppSample == nullptr)
    {
        return E_POINTER;
    }

    *ppSample = nullptr;
    if (pChosenTimestampHns != nullptr)
    {
        *pChosenTimestampHns = 0;
    }

    IMFSample* pBefore = nullptr;
    LONGLONG beforeTimestamp = 0;
    bool hasBefore = false;

    HRESULT hr = S_OK;

    for (;;)
    {
        IMFSample* pCurrent = nullptr;
        DWORD flags = 0;
        LONGLONG currentTimestamp = 0;
        LONGLONG diffBefore = 0;
        LONGLONG diffCurrent = 0;

        hr = pReader->ReadSample(
            MF_SOURCE_READER_FIRST_VIDEO_STREAM,
            0,
            nullptr,
            &flags,
            &currentTimestamp,
            &pCurrent);

        if (FAILED(hr))
        {
            SafeRelease(&pCurrent);
            break;
        }

        if ((flags & MF_SOURCE_READERF_ENDOFSTREAM) != 0)
        {
            SafeRelease(&pCurrent);

            if (hasBefore)
            {
                *ppSample = pBefore;
                pBefore = nullptr;

                if (pChosenTimestampHns != nullptr)
                {
                    *pChosenTimestampHns = beforeTimestamp;
                }

                hr = S_OK;
            }
            else
            {
                hr = MF_E_END_OF_STREAM;
            }
            break;
        }

        if ((flags & MF_SOURCE_READERF_STREAMTICK) != 0)
        {
            SafeRelease(&pCurrent);
            continue;
        }

        if (pCurrent == nullptr)
        {
            continue;
        }

        if (currentTimestamp < targetHns)
        {
            SafeRelease(&pBefore);
            pBefore = pCurrent;
            pCurrent = nullptr;
            beforeTimestamp = currentTimestamp;
            hasBefore = true;
            continue;
        }

        if (hasBefore)
        {
            diffBefore = targetHns - beforeTimestamp;
            diffCurrent = currentTimestamp - targetHns;

            if (diffBefore <= diffCurrent)
            {
                *ppSample = pBefore;
                pBefore = nullptr;

                if (pChosenTimestampHns != nullptr)
                {
                    *pChosenTimestampHns = beforeTimestamp;
                }

                SafeRelease(&pCurrent);
            }
            else
            {
                *ppSample = pCurrent;
                pCurrent = nullptr;

                if (pChosenTimestampHns != nullptr)
                {
                    *pChosenTimestampHns = currentTimestamp;
                }
            }
        }
        else
        {
            *ppSample = pCurrent;
            pCurrent = nullptr;

            if (pChosenTimestampHns != nullptr)
            {
                *pChosenTimestampHns = currentTimestamp;
            }
        }

        hr = S_OK;
        break;
    }

    SafeRelease(&pBefore);
    return hr;
}

HRESULT CopyContiguousBufferToTopDownBgra(
    IMFMediaBuffer* pBuffer,
    LONG defaultStride,
    UINT32 width,
    UINT32 height,
    std::vector<BYTE>& pixels,
    UINT32* pStride)
{
    if (pBuffer == nullptr || pStride == nullptr)
    {
        return E_POINTER;
    }

    BufferLock lock(pBuffer);

    BYTE* pScanLine0 = nullptr;
    LONG actualStride = 0;

    HRESULT hr = lock.LockBuffer(defaultStride, height, &pScanLine0, &actualStride);
    if (FAILED(hr))
    {
        return hr;
    }

    if (width > (std::numeric_limits<UINT32>::max() / 4))
    {
        return E_INVALIDARG;
    }

    const UINT32 destStride = width * 4;
    const LONG actualStrideAbs = (actualStride < 0) ? -actualStride : actualStride;
    if (actualStrideAbs < static_cast<LONG>(destStride))
    {
        return E_UNEXPECTED;
    }

    pixels.resize(static_cast<size_t>(destStride) * height);

    BYTE* pDestRow = pixels.data();
    BYTE* pSrcRow = pScanLine0;

    for (UINT32 y = 0; y < height; ++y)
    {
        std::memcpy(pDestRow, pSrcRow, destStride);

        // MFVideoFormat_RGB32의 4 byte째는 alpha라고 할 수 없으므로,
        // PNG 저장 전에 불투명으로 고정한다.
        for (UINT32 x = 0; x < width; ++x)
        {
            pDestRow[static_cast<size_t>(x) * 4 + 3] = 0xFF;
        }

        pDestRow += destStride;
        pSrcRow += actualStride;
    }

    *pStride = destStride;
    return S_OK;
}

HRESULT CopySampleToTopDownBgra(
    IMFSample* pSample,
    IMFMediaType* pCurrentType,
    std::vector<BYTE>& pixels,
    UINT32* pWidth,
    UINT32* pHeight,
    UINT32* pStride)
{
    if (pSample == nullptr || pCurrentType == nullptr ||
        pWidth == nullptr || pHeight == nullptr || pStride == nullptr)
    {
        return E_POINTER;
    }

    *pWidth = 0;
    *pHeight = 0;
    *pStride = 0;

    IMFMediaBuffer* pBuffer = nullptr;

    GUID subtype = GUID_NULL;
    UINT32 width = 0;
    UINT32 height = 0;
    LONG defaultStride = 0;

    HRESULT hr = pCurrentType->GetGUID(MF_MT_SUBTYPE, &subtype);
    if (FAILED(hr))
    {
        goto done;
    }

    if (!IsEqualGUID(subtype, MFVideoFormat_RGB32))
    {
        hr = MF_E_INVALIDMEDIATYPE;
        goto done;
    }

    hr = MFGetAttributeSize(pCurrentType, MF_MT_FRAME_SIZE, &width, &height);
    if (FAILED(hr))
    {
        goto done;
    }

    if (width == 0 || height == 0)
    {
        hr = E_UNEXPECTED;
        goto done;
    }

    hr = GetDefaultStride(pCurrentType, &defaultStride);
    if (FAILED(hr))
    {
        goto done;
    }

    hr = pSample->ConvertToContiguousBuffer(&pBuffer);
    if (FAILED(hr))
    {
        goto done;
    }

    hr = CopyContiguousBufferToTopDownBgra(
        pBuffer,
        defaultStride,
        width,
        height,
        pixels,
        pStride);
    if (FAILED(hr))
    {
        goto done;
    }

    *pWidth = width;
    *pHeight = height;

    hr = S_OK;

done:
    SafeRelease(&pBuffer);
    return hr;
}

HRESULT SaveBgraToPng(
    PCWSTR outputPath,
    const BYTE* pixels,
    UINT32 width,
    UINT32 height,
    UINT32 stride)
{
    if (outputPath == nullptr || pixels == nullptr)
    {
        return E_POINTER;
    }

    if (width == 0 || height == 0 || stride < width * 4)
    {
        return E_INVALIDARG;
    }

    const size_t bufferSizeSizeT = static_cast<size_t>(stride) * height;
    if (bufferSizeSizeT > static_cast<size_t>(std::numeric_limits<UINT>::max()))
    {
        return E_INVALIDARG;
    }

    const UINT bufferSize = static_cast<UINT>(bufferSizeSizeT);

    IWICImagingFactory* pFactory = nullptr;
    IWICStream* pStream = nullptr;
    IWICBitmapEncoder* pEncoder = nullptr;
    IWICBitmapFrameEncode* pFrame = nullptr;
    IPropertyBag2* pProps = nullptr;
    WICPixelFormatGUID pixelFormat = GUID_WICPixelFormat32bppBGRA;

    HRESULT hr = CoCreateInstance(
        CLSID_WICImagingFactory,
        nullptr,
        CLSCTX_INPROC_SERVER,
        IID_PPV_ARGS(&pFactory));
    if (FAILED(hr))
    {
        goto done;
    }

    hr = pFactory->CreateStream(&pStream);
    if (FAILED(hr))
    {
        goto done;
    }

    hr = pStream->InitializeFromFilename(outputPath, GENERIC_WRITE);
    if (FAILED(hr))
    {
        goto done;
    }

    hr = pFactory->CreateEncoder(GUID_ContainerFormatPng, nullptr, &pEncoder);
    if (FAILED(hr))
    {
        goto done;
    }

    hr = pEncoder->Initialize(pStream, WICBitmapEncoderNoCache);
    if (FAILED(hr))
    {
        goto done;
    }

    hr = pEncoder->CreateNewFrame(&pFrame, &pProps);
    if (FAILED(hr))
    {
        goto done;
    }

    hr = pFrame->Initialize(pProps);
    if (FAILED(hr))
    {
        goto done;
    }

    hr = pFrame->SetSize(width, height);
    if (FAILED(hr))
    {
        goto done;
    }

    hr = pFrame->SetPixelFormat(&pixelFormat);
    if (FAILED(hr))
    {
        goto done;
    }

    if (!IsEqualGUID(pixelFormat, GUID_WICPixelFormat32bppBGRA))
    {
        hr = WINCODEC_ERR_UNSUPPORTEDPIXELFORMAT;
        goto done;
    }

    hr = pFrame->WritePixels(
        height,
        stride,
        bufferSize,
        const_cast<BYTE*>(pixels));
    if (FAILED(hr))
    {
        goto done;
    }

    hr = pFrame->Commit();
    if (FAILED(hr))
    {
        goto done;
    }

    hr = pEncoder->Commit();

done:
    SafeRelease(&pProps);
    SafeRelease(&pFrame);
    SafeRelease(&pEncoder);
    SafeRelease(&pStream);
    SafeRelease(&pFactory);
    return hr;
}

HRESULT ExtractFrameFromMp4ToPng(
    PCWSTR inputPath,
    LONGLONG targetHns,
    PCWSTR outputPath,
    LONGLONG* pActualTimestampHns)
{
    if (inputPath == nullptr || outputPath == nullptr)
    {
        return E_POINTER;
    }

    if (targetHns < 0)
    {
        return E_INVALIDARG;
    }

    MediaFoundationScope mf;
    HRESULT hr = mf.Initialize();
    if (FAILED(hr))
    {
        return hr;
    }

    IMFSourceReader* pReader = nullptr;
    IMFMediaType* pCurrentType = nullptr;
    IMFSample* pChosenSample = nullptr;

    LONGLONG durationHns = 0;
    UINT32 width = 0;
    UINT32 height = 0;
    UINT32 stride = 0;
    std::vector<BYTE> pixels;

    hr = CreateConfiguredSourceReader(inputPath, &pReader);
    if (FAILED(hr))
    {
        goto done;
    }

    hr = pReader->GetCurrentMediaType(
        MF_SOURCE_READER_FIRST_VIDEO_STREAM,
        &pCurrentType);
    if (FAILED(hr))
    {
        goto done;
    }

    hr = GetPresentationDuration(pReader, &durationHns);
    if (FAILED(hr))
    {
        goto done;
    }

    if (targetHns >= durationHns)
    {
        hr = E_INVALIDARG;
        goto done;
    }

    hr = SeekSourceReader(pReader, targetHns);
    if (FAILED(hr))
    {
        goto done;
    }

    hr = ReadNearestVideoSample(
        pReader,
        targetHns,
        &pChosenSample,
        pActualTimestampHns);
    if (FAILED(hr))
    {
        goto done;
    }

    hr = CopySampleToTopDownBgra(
        pChosenSample,
        pCurrentType,
        pixels,
        &width,
        &height,
        &stride);
    if (FAILED(hr))
    {
        goto done;
    }

    hr = SaveBgraToPng(outputPath, pixels.data(), width, height, stride);

done:
    SafeRelease(&pChosenSample);
    SafeRelease(&pCurrentType);
    SafeRelease(&pReader);
    return hr;
}

bool TryParseSeconds(PCWSTR text, LONGLONG* phns)
{
    if (text == nullptr || phns == nullptr)
    {
        return false;
    }

    wchar_t* end = nullptr;
    errno = 0;

    const double seconds = std::wcstod(text, &end);
    if (end == text || *end != L'\0' || errno != 0)
    {
        return false;
    }

    if (!std::isfinite(seconds) || seconds < 0.0)
    {
        return false;
    }

    const long double hns =
        static_cast<long double>(seconds) * 10000000.0L;

    if (hns < 0.0L ||
        hns > static_cast<long double>(std::numeric_limits<LONGLONG>::max()))
    {
        return false;
    }

    *phns = static_cast<LONGLONG>(std::llround(hns));
    return true;
}

double HnsToSeconds(LONGLONG hns)
{
    return static_cast<double>(hns) / 10000000.0;
}

void PrintUsage()
{
    std::fwprintf(stderr, L"Usage:\n");
    std::fwprintf(stderr, L"  ExtractFrameFromMp4.exe <input.mp4> <seconds> <output.png>\n");
    std::fwprintf(stderr, L"\nExample:\n");
    std::fwprintf(stderr, L"  ExtractFrameFromMp4.exe input.mp4 12.345 output.png\n");
}

int wmain(int argc, wchar_t* argv[])
{
    if (argc != 4)
    {
        PrintUsage();
        return 1;
    }

    LONGLONG targetHns = 0;
    if (!TryParseSeconds(argv[2], &targetHns))
    {
        std::fwprintf(stderr, L"Invalid seconds: %ls\n", argv[2]);
        return 1;
    }

    LONGLONG actualHns = 0;
    HRESULT hr = ExtractFrameFromMp4ToPng(
        argv[1],
        targetHns,
        argv[3],
        &actualHns);

    if (FAILED(hr))
    {
        std::fwprintf(stderr, L"Failed. HRESULT = 0x%08lX\n", static_cast<unsigned long>(hr));
        return 1;
    }

    std::wprintf(L"Saved: %ls\n", argv[3]);
    std::wprintf(L"Requested: %.3f sec\n", HnsToSeconds(targetHns));
    std::wprintf(L"Actual: %.3f sec\n", HnsToSeconds(actualHns));
    return 0;
}

관련 기사

같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.

관련 토픽

이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.

이 주제와 연결되는 서비스

이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.

블로그 목록으로 돌아가기