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

· 업데이트: · · Media Foundation, C++, Windows 개발, WIC

수정 이력(1건, 최종 수정 2026년 09월 01일)

이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.

일본어 원문의 완역으로 다시 번역했습니다. 기존 한국어판은 원문의 일부만 옮긴 축약본이라 절, 표, Mermaid 그림, 그림 설명, FAQ가 빠져 있었습니다. 일본어 원문에 맞춰 이들을 모두 복원했고, 기술적인 주장은 일본어판과 같습니다. 수정 전 버전 보기 (DOI: 10.5281/zenodo.21635134)
최초 공개
이 글을 인용하기(DOI: 10.5281/zenodo.21635133)

이 글은 Zenodo에 보관되어 있습니다. 항상 최신 버전으로 연결되는 DOI와 지금 보고 있는 버전에 고정된 DOI를 아래에 함께 제시합니다.

小村 豪 (2026). 「Media Foundation으로 MP4 동영상의 지정 시각에서 정지 이미지를 뽑는 방법 - .cpp에 그대로 붙일 수 있는 1파일 완결판」. 합동회사 코무라소프트. https://doi.org/10.5281/zenodo.21635133 https://comcomponent.com/ko/blog/2026/03/15/000-media-foundation-extract-still-image-from-mp4-at-specific-time/

DOI(최신 버전)
10.5281/zenodo.21635133
DOI(이 버전)
10.5281/zenodo.22217435

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

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

거칠게 진행했을 때 일어나는 사고seek해서 1번 읽고 저장하는 것만으로는 key frame과 timestamp, stride, 상하 방향, RGB32의 4바이트째가 얽혀, 시각 어긋남·상하 반전·투명 PNG라는 사고가 일어남을 보여주는 그림.seek해서 1번 읽고 저장시각이 조금 어긋난다이미지가 상하로 뒤집힌다PNG가 묘하게 투명해진다

그림 1: 언뜻 그것으로 끝나 보이는 구현은 시각 어긋남·상하 반전·투명 PNG라는 수수한 사고로 이어진다.

Media Foundation의 전체적인 모습 자체는 예전에 쓴 Media Foundation 입문 - COM 관점에서 API 이해하기 도 참고가 됩니다. 이번에는 거기서 한 단계 내려와 MP4에서 1장 뽑는 부분에만 좁힙니다.

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

또한 이 글에 나오는 코드는 샘플 코드 일습(1파일 완결 C++ 콘솔 앱)으로 GitHub에 공개하고 있습니다.

media-foundation-extract-still-image-from-mp4-at-specific-time - komurasoft-blog-samples (GitHub)

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 저장 정도까지 하면 꽤 안정적입니다.

거친 흐름과 안정적인 흐름seek해서 1번 읽고 저장하는 흐름은 거칠고, seek 후 timestamp를 보면서 전후를 비교하고 stride를 의식해 복사한 뒤 PNG로 저장하는 흐름으로 하면 꽤 안정적임을 보여주는 그림.1번 읽고 저장은 거칠다seektimestamp를 보면서 전후 비교stride를 의식해 복사PNG 저장어긋남·붕괴의 원인

그림 2: seek 직후에 저장하지 않고 전후 비교와 stride를 의식한 복사를 끼워 넣으면 안정적이다.

이 글의 지식 맵

이 글은 Media Foundation의 IMFSourceReader를 사용해 MP4에서 지정 시각에 가장 가까운 정지 이미지 1장을 꺼내는 방법을 정리합니다. SetCurrentPosition에 의한 seek는 exact가 아니라 보통 key frame 쪽으로 쏠리기 때문에, GOP가 긴 동영상일수록 오차가 생기기 쉬우며, seek 후 ReadSample을 반복해 timestamp의 전후를 비교할 필요가 있습니다. ReadSample은 성공했더라도 sample이 null인 경우가 있으므로 확인이 필요합니다. 꺼낸 MFVideoFormat_RGB32 프레임은 stride와 상하 방향을 IMF2DBuffer::Lock2D로 흡수해 top-down의 BGRA로 정형하고, 4바이트째를 alpha로 0xFF에 고정한 뒤 WIC로 PNG로 저장함으로써, 의도하지 않은 투명 PNG가 되는 문제를 방지합니다.

MP4 정지 이미지 추출(Media Foundation)의 지식 맵IMFSourceReader의 SetCurrentPosition에 의한 seek가 key frame 쪽으로 쏠리는 것, GOP 길이와 ReadSample에서의 전후 비교의 관계, stride와 top-down/bottom-up 방향을 Lock2D로 흡수하는 처리, RGB32의 4바이트째를 alpha로 고정해 WIC로 PNG 저장하기까지의 관계를 보여주는 그림이용한다전제로 한다이용한다전제로 한다원인이 될 수 있다이용한다전제로 한다전제로 한다이용한다전제로 한다원인이 될 수 있다방지한다이용한다이용한다원인이 될 수 있다동영상 프레임 추출(지정 시각)IMFSourceReader(Source Reader)Media FoundationSetCurrentPosition을 통한 seekIMFSourceReader::ReadSampleGOP(Group of Pictures)키프레임(key frame)stride(이미지 행 바이트 수)IMF2DBuffer::Lock2Dtop-down / bottom-up(이미지 상하 방향)MFVideoFormat_RGB32의도치 않은 투명 PNGalpha 0xFF 고정WIC(Windows Imaging Component)ReadSample의 null 샘플

그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 15건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle

2. 이 글의 전제

2.1. 대상 독자와 전제 지식

  • C++로 Windows의 COM API를 호출해 본 적이 있는 분을 대상으로 합니다. CoInitializeEx로 초기화하고 인터페이스 포인터를 Release로 해제한다는 흐름을 알면 충분합니다
  • 반환값인 HRESULTSUCCEEDED / FAILED 매크로로 판정합니다. 실패했을 때는 16진수 값을 그대로 읽습니다. 0x8007로 시작하는 값은 Win32 오류에서 온 것으로, 하위 16 bit가 Win32 오류 코드입니다(0x80070057E_INVALIDARG와 같은 값입니다). MF_E_로 시작하는 Media Foundation 고유 오류는 mferror.h에 정의되어 있습니다
  • Media Foundation 자체는 처음이어도 읽을 수 있게 되어 있습니다. COM 관점에서 본 전체상은 Media Foundation 입문 - COM 관점에서 API 이해하기 에 정리해 두었습니다

2.2. 개발 환경

항목 이번 전제
OS Windows 10 / Windows 11
IDE Visual Studio 2022(워크로드 「C++를 사용한 데스크톱 개발」)
SDK Visual Studio에 동봉된 Windows SDK(Media Foundation과 WIC의 헤더와 라이브러리를 포함합니다)
프로젝트 C++의 「콘솔 앱」 템플릿
플랫폼 x64
추가 라이브러리 없음. mfplat.lib 등의 링크 지정은 코드 안의 #pragma comment(lib, ...)로 해결합니다

붙여넣는 방법이나 프리컴파일 헤더 관련 세세한 주의점은 「7. 빌드와 실행 메모」에 정리해 두었습니다.

2.3. 입력과 출력의 전제

이번 전제는 이렇습니다.

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

재생, 음성 동기화, 탐색 바(seek bar), UI 연동까지 하려면 다른 설계도 있지만, 1프레임을 얻고 싶다는 용도라면 이쪽이 꽤 명료합니다.

이번 전제의 구도로컬 MP4를 입력으로, 동기 모드의 IMFSourceReader로 지정 시각에 가장 가까운 프레임을 얻고, WIC로 PNG로 저장한다는 외부 라이브러리 없는 전제 구성을 보여주는 그림.로컬 MP4동기 모드의 IMFSourceReader지정 시각에 가장 가까운 프레임WIC로 PNG 저장Windows 표준 API만으로 완결

그림 3: 입력부터 저장까지를 외부 라이브러리 없는 동기 Source Reader 구성으로 통과시킨다.

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장을 얻기 쉬워집니다.

가장 가까운 1장을 고르는 판정 규칙seek 후 ReadSample을 진행해 target보다 앞의 마지막 sample을 보유하고, target 이후의 최초 sample이 오면 양쪽의 차이를 비교해 target에 더 가까운 쪽을 채용하는 판정 규칙을 보여주는 그림.seek 후 ReadSample을 진행한다target보다 앞의 마지막 sample을 보유target 이후의 최초 sample이 온다양쪽과 target의 차이를 비교더 가까운 쪽을 채용

그림 4: 전후 2장의 후보를 비교한 뒤 채용함으로써, 「target 이후의 최초 1장」이 아니라 가장 가까운 1장을 얻는다.

3.3. 처리 이미지

처리의 처음부터 끝까지는 대략 input.mp4 -> Source Reader를 만든다 -> RGB32를 요청한다 -> seek한다 -> ReadSample을 반복한다 -> target 전후를 비교한다 -> top-down BGRA로 다시 채운다 -> WIC로 PNG 저장 입니다.

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

처리에 숨은 4가지 함정처리의 겉모습은 단순하지만 seek의 정밀도, sample의 null, stride, RGB32의 4바이트째라는 4가지 작은 함정이 있고, 이를 밟지 않으면 구현은 자연스럽게 정리됨을 보여주는 그림.겉보기는 단순한 처리seek의 정밀도sample의 nullstride와 방향4바이트째 취급밟지 않으면 자연스럽게 정리된다

그림 5: 단순해 보이는 흐름 곳곳에 4가지 함정이 있고, 그곳만 의식하면 구현은 자연스럽게 정리된다.

4. 먼저 짚어 둘 함정

이 장부터 나오는 용어를 먼저 짧게 정리해 둡니다.

용어 의미
key frame(키 프레임) 전후 프레임을 참조하지 않고 그 자체만으로 디코드할 수 있는 프레임입니다. H.264에서는 IDR 픽처 등이 해당합니다
GOP(Group of Pictures) 어떤 key frame부터 다음 key frame 직전까지 프레임의 묶음입니다. GOP가 길수록 key frame의 간격이 벌어지므로, seek 후의 위치가 지정 시각에서 멀어지기 쉬워집니다
stride 이미지 버퍼에서 1행이 차지하는 바이트 수입니다. 너비 × 1픽셀의 바이트 수와 일치한다고는 할 수 없고, 행 끝에 padding이 들어가는 경우가 있습니다
top-down / bottom-up 이미지 버퍼의 맨 앞이 위쪽 끝 행인지 아래쪽 끝 행인지의 차이입니다. bottom-up은 stride가 음수 값으로 표현됩니다
MF_SOURCE_READERF_STREAMTICK ReadSample이 반환하는 flag 중 하나로, 스트림에 갭(데이터 끊김)이 있음을 나타냅니다. 이 flag가 선 호출에서는 프레임을 얻을 수 없으므로 다시 읽습니다

4.1. SetCurrentPosition은 exact seek가 아니다

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

이 때문에 다음과 같은 구현은 꽤 위태롭습니다.

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

GOP가 긴 동영상에서는 이렇게 하면 어긋납니다. key frame의 간격이 2초 벌어진 동영상이라면, 지정 시각보다 최대 2초 가까이 앞선 프레임을 저장하게 됩니다.

seek 직후의 1회 읽기가 어긋나는 구조SetCurrentPosition은 지정 위치보다 조금 앞, 특히 key frame 쪽으로 쏠리기 때문에, 직후에 1번만 읽어 저장하면 GOP가 긴 동영상에서는 지정 시각보다 상당히 앞선 프레임을 저장하게 됨을 보여주는 그림.SetCurrentPosition(target)key frame 쪽으로 조금 앞에 착지ReadSample을 1번만앞선 프레임을 저장하게 된다GOP가 길수록 어긋남이 커진다

그림 6: seek는 key frame 쪽으로 쏠리므로, 1회 읽기로는 GOP 길이만큼 앞선 그림을 저장할 수 있다.

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

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

HRESULT만 보고 pSample을 곧바로 참조하는 것은 위험합니다. HRESULT, flags, pSample을 3점 세트로 보는 정도가 안전합니다.

ReadSample 결과의 3점 확인ReadSample은 S_OK여도 sample이 NULL일 수 있으므로, HRESULT와 flags와 pSample을 3점 세트로 확인해 끝이나 스트림 갭의 flag를 처리할 필요가 있음을 보여주는 그림.ReadSample의 결과HRESULT를 본다flags를 본다pSample을 본다끝이나 갭의 flag가 있을 수 있다S_OK여도 NULL일 수 있다

그림 7: 성공 코드만으로 참조하지 말고 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로 넘깁니다. 여기를 먼저 정리해 두면 저장 측이 꽤 단순해집니다.

stride와 방향의 흡수IMF2DBuffer의 Lock2D로 scan line 0의 선두 포인터와 실제 stride를 얻고, bottom-up에서 stride가 음수인 경우도 포함해 top-down의 연속 BGRA 버퍼로 다시 채운 뒤 PNG로 넘기는 흐름을 보여주는 그림.Lock2D로 취득scan line 0의 선두 포인터실제 stridebottom-up에서는 음수가 될 수 있다top-down의 연속 BGRA로 다시 채운다저장 측이 단순해진다

그림 8: Lock2D로 실제 stride와 방향을 흡수하고, top-down의 연속 BGRA로 정리한 뒤 저장에 넘긴다.

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로 채워 완전히 불투명하게 만드는 방침으로 합니다.

RGB32의 4바이트째 취급MFVideoFormat_RGB32는 byte 0부터 2까지가 B·G·R이고 4바이트째는 alpha라고는 할 수 없으므로, 그대로 PNG에 쓰면 투명해질 수 있고, 저장 전에 alpha를 0xFF로 채워 불투명하게 만드는 방침을 보여주는 그림.그대로 저장0xFF로 채운 뒤 저장MFVideoFormat_RGB32 프레임4바이트째는 alpha라고는 할 수 없다투명 PNG가 될 수 있다완전 불투명 PNG

그림 9: 4바이트째를 단정하지 않고, 저장 전에 0xFF로 채워 불투명으로 고정한다.

5. 구현의 흐름

5.1. Source Reader를 동기 모드로 만든다

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

Reader 생성 시에 하는 일은 이 4가지입니다.

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

이렇게 하면 뒷단은 「RGB32 프레임을 받는다」는 전제로 작성하기 쉬워집니다.

Reader 생성 시의 4단계비디오 처리를 활성화하고, 모든 stream을 일단 off한 뒤 첫 번째 비디오 stream만 on으로 하고, 출력 type을 RGB32로 설정하는 Reader 생성 시 4단계의 순서를 보여주는 그림.비디오 처리를 활성화모든 stream을 off첫 번째 비디오 stream만 on출력 type을 RGB32로 설정뒷단은 RGB32 전제로 작성 가능

그림 10: Reader 생성 시의 4단계를 순서대로 마치면, 뒷단은 RGB32 프레임을 받기만 하면 된다.

5.2. seek 뒤에 timestamp를 보면서 채운다

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

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

seek 후 전후를 비교하는 대화앱이 SetCurrentPosition으로 seek한 뒤, ReadSample을 반복해 timestamp를 확인하고 target을 넘은 시점에서 전후 sample을 비교해 더 가까운 쪽을 고르는 흐름을 보여주는 그림.Source ReaderSource Readerloop[target을 넘을 때까지]SetCurrentPosition(target)ReadSamplesample과 timestamp전후 sample을 비교해 더 가까운 쪽을 채용

그림 11: seek 직후에 저장하지 않고, target을 넘을 때까지 읽어 나간 뒤 전후를 비교한다.

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 이미지」로 다룰 수 있습니다.

sample에서 BGRA로의 정형 절차ConvertToContiguousBuffer로 1개의 버퍼로 만들고, BufferLock으로 scan line 0과 실제 stride를 얻어, 행별로 top-down 버퍼로 복사하고 alpha를 0xFF로 만드는 정형 절차를 보여주는 그림.ConvertToContiguousBufferBufferLock으로 scan line 0과 stride행별로 top-down으로 복사alpha를 0xFF로그냥 32bpp BGRA 이미지

그림 12: 4단계의 정형을 거치면 저장 측은 그냥 32bpp BGRA 이미지로 다룰 수 있다.

5.4. PNG 저장은 WIC에 맡긴다

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

Media Foundation과 WIC의 분담Media Foundation이 프레임 취득을 맡고, WIC가 이미지화와 PNG 출력을 맡는다는 분담을 보여주는 그림.프레임을 얻는다WIC가 이미지화Media FoundationBGRA 프레임PNG 파일

그림 13: 프레임 취득은 Media Foundation, 이미지화는 WIC라는 분담으로, 표준 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와 메모리 대역을 여분으로 쓴다
포맷 변화 도중에 해상도가 바뀌는 특수 동영상은 다른 설계로 한다 폭·높이 전제가 깨진다

이 중 「연속 추출」과 「copy 횟수」 2행만은 1장 추출 자체가 아니라, 같은 동영상에서 수십 장을 뽑도록 확장했을 때 효과를 발휘하는 항목입니다. 이번 샘플은 1장을 얻고 종료하는 구성이므로, 여러 장을 뽑을 때는 Source Reader를 다시 만들지 말고 SetCurrentPositionReadSample의 반복으로 바꾸십시오. Source Reader 이외의 선택지(Media Session 등)를 포함한 Media Foundation 전체의 지도는 Media Foundation 입문 - COM 관점에서 API 이해하기 에 있습니다.

1장 추출에서 연속 추출로의 확장 방법1장만 뽑는다면 이번 구성 그대로 괜찮고, 같은 동영상에서 여러 장을 뽑을 때는 Source Reader를 다시 만들지 않고 seek와 ReadSample을 반복하는 형태로 바꾸는 확장 방법을 보여주는 그림.1장만여러 장몇 장을 뽑는가이번 구성 그대로Reader를 다시 만들지 않고 seek와 읽기를 반복copy 횟수의 비용도 의식한다

그림 14: 여러 장을 뽑도록 확장할 때는 Reader를 다시 만들지 않고 seek와 읽기의 반복으로 바꾼다.

7. 빌드와 실행 메모

이 글 마지막의 코드는 Visual Studio C++ 콘솔 앱에 1개의 .cpp로 추가하기 쉬운 형태로 되어 있습니다. 환경 자체의 전제는 2.2에 정리해 두었습니다.

7.1. 빌드 메모

알아 두면 편한 점을 들어 둡니다.

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

7.2. 실행 방법과, 성공했을 때 나오는 것

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

성공하면 wmain의 마지막에서 표준 출력으로 다음 3행이 출력됩니다.

Saved: C:\work\frame.png
Requested: 12.345 sec
Actual: (채용한 프레임의 표시 시각) sec

Requested는 인자로 넘긴 초 수 그 자체이고, Actual은 실제로 채용한 프레임의 timestamp입니다. 3.2의 규칙으로 전후 프레임 중 더 가까운 쪽을 취하므로, 양쪽의 차이는 프레임 간격의 대략 절반 이내에 들어갑니다(29.97fps라면 17ms 정도가 상한의 기준입니다). 여기가 수백 ms 이상 떨어져 있다면, seek 후 1번만 읽었다든가 하는 이유로 4.1의 전후 비교가 작동하지 않는 것을 의심하십시오.

Requested와 Actual 차이를 보는 법Requested와 Actual의 차이가 프레임 간격의 절반 이내에 들어가 있으면 정상이고, 수백 밀리초 이상 떨어져 있다면 seek 후의 전후 비교가 작동하지 않는 것을 의심한다는 진단 흐름을 보여주는 그림.프레임 간격의 절반 이내수백 ms 이상Requested와 Actual의 차이를 본다전후 비교가 작동하고 있다전후 비교가 작동하지 않을 가능성seek 후에 1번만 읽지 않았는지 등을 확인

그림 15: 차이가 프레임 간격의 절반을 크게 넘는다면 전후 비교 구현을 의심한다.

동작 확인의 목표는 이 3가지입니다.

  • 종료 코드가 0이고, Saved: 행에 지정한 출력 경로가 나와 있다
  • 출력된 PNG를 열면 그 시각의 장면이 올바른 방향(상하가 뒤집히지 않음)으로 표시된다
  • PNG의 폭·높이가 원본 동영상의 해상도와 일치하고, 배경이 비치지 않는다(4.4의 alpha 채우기가 작동하고 있다)

실패한 경우 표준 오류 출력에 Failed. HRESULT = 0x........가 출력됩니다. 값은 2.1의 요령으로 읽습니다. 지정 초 수가 동영상 길이 이상일 때는 0x80070057(E_INVALIDARG)이 됩니다.

실행 결과 확인 흐름실행이 성공하면 3행의 출력과 PNG의 방향·해상도·불투명을 확인하고, 실패하면 표준 오류 출력의 HRESULT를 읽으며, 지정 초 수가 동영상 길이 이상이면 E_INVALIDARG가 된다는 확인 흐름을 보여주는 그림.성공실패실행한다3행의 출력과 PNG를 확인표준 오류의 HRESULT를 읽는다초 수가 동영상 길이 이상이면 E_INVALIDARG

그림 16: 성공하면 출력과 PNG의 3가지를 확인하고, 실패하면 HRESULT 값에서 원인을 읽는다.

8. 정리

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

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

이 정도까지 짚어 두면 꽤 사고가 나기 어려워집니다.

짚어 둘 5가지seek는 exact가 아니라는 것, timestamp의 전후 비교, sample이 없는 경우의 확인, stride와 방향의 흡수, RGB32의 4바이트째를 단정하지 않는 것의 5가지를 짚어 두면 사고가 나기 어려워짐을 보여주는 그림.seek는 exact가 아니다timestamp를 전후 비교성공이어도 sample 없음을 확인stride와 방향을 흡수4바이트째를 단정하지 않는다사고가 나기 어려운 구현

그림 17: 5가지 짚을 점이 갖추어지면, 지정 시각의 정지 이미지 추출은 꽤 사고가 나기 어려워진다.

이번 샘플은 1장을 제대로 뽑는 것에 좁힌 최소 구성입니다. 썸네일 생성, 감시 영상의 대표 frame 저장, 검사 로그의 증거 출력 부근에는 그대로 가져갈 수 있는 형태로 되어 있습니다.

9. 참고 자료

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

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

긴 1블록이므로, 어떤 함수가 본문의 어디에 대응하는지를 먼저 두어 둡니다. 읽을 때도, 동작하지 않을 때의 조사에서도, 이 대응표부터 들어가는 것이 빠릅니다.

코드 중의 함수·클래스 대응하는 본문 역할과 대처하고 있는 함정
MediaFoundationScope 5.1 CoInitializeExMFStartup의 초기화·뒷정리를 정리한다
CreateConfiguredSourceReader 5.1 Reader를 만들고 동영상 스트림만 선택해 MFVideoFormat_RGB32를 요청한다
GetPresentationDuration 6.(시각 범위) 동영상 길이를 얻어 0 <= target < duration을 만족하는지 확인한다
SeekSourceReader 4.1 / 5.2 SetCurrentPosition으로 seek한다. 이것만으로는 exact가 되지 않는다
ReadNearestVideoSample 3.2 / 4.1 / 4.2 / 5.2 target의 전후를 비교해 1장을 고른다. flagspSample의 null도 여기서 처리한다
GetDefaultStride 4.3 MF_MT_DEFAULT_STRIDE가 없을 때 stride를 계산해 보완한다
BufferLock 4.3 / 5.3 IMF2DBuffer::Lock2D로 scan line 0과 실제 stride를 얻는다
CopyContiguousBufferToTopDownBgra 4.3 / 4.4 / 5.3 행 단위로 top-down 버퍼로 다시 채우고, 4바이트째를 0xFF로 채운다
CopySampleToTopDownBgra 5.3 frame size와 stride를 꺼내어 위의 복사를 호출한다
SaveBgraToPng 5.4 WIC로 32bpp BGRA를 PNG로 써낸다
ExtractFrameFromMp4ToPng 5. 전체 위 함수들을 순서대로 호출하는 입구
TryParseSeconds / wmain 7.2 인자 분석과 Requested / Actual의 표시
#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;
}

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

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

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

자주 묻는 질문

이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.

MP4에서 지정 시각의 정지 이미지를 뽑으려면 어떤 API를 쓰나요?
1장만 뽑는다면 Media Session보다 IMFSourceReader가 입구로서 자연스럽습니다. MFCreateSourceReaderFromURL로 파일을 열고, SetStreamSelection으로 동영상만 선택하고, MFVideoFormat_RGB32를 요청한 뒤, SetCurrentPosition으로 seek하고 ReadSample로 프레임을 얻어 WIC로 PNG에 저장하는 흐름입니다. 외부 라이브러리 없이 Windows 표준 API만으로 완결할 수 있습니다.
SetCurrentPosition으로 지정 시각의 프레임을 정확히 얻을 수 있나요?
얻을 수 없습니다. IMFSourceReader::SetCurrentPosition은 exact seeking을 보증하지 않고, 동영상에서는 보통 지정 위치보다 조금 앞, 특히 key frame 쪽으로 쏠립니다. seek 후 ReadSample을 진행해 timestamp를 보면서, target 직전의 마지막 sample과 target 이후의 최초 sample을 비교해 더 가까운 쪽을 채용하는 구현이 필요합니다. seek 후 한 번만 읽어 저장하는 구현은 GOP가 긴 동영상에서 흔히 어긋납니다.
저장한 PNG가 투명하게 나오는 원인은 무엇인가요?
MFVideoFormat_RGB32의 4바이트째가 alpha라고는 할 수 없기 때문입니다. Windows의 32bit RGB는 byte 0, 1, 2가 B, G, R이고 byte 3은 alpha일 수도 ignore일 수도 있습니다(ARGB32가 아닙니다). 그대로 PNG에 쓰면 투명 이미지가 될 수 있으므로, 저장 전에 4바이트째를 0xFF로 채워 불투명하게 만들어 두는 것이 안전합니다.
꺼낸 이미지가 무너지거나 상하가 뒤집히는 이유는 무엇인가요?
stride와 상하 방향 처리 때문입니다. 이미지 버퍼는 width × bytesPerPixel로 일직선으로 채워져 있다고는 할 수 없고, 행 끝에 padding이 들어가는 경우도 있으며, RGB 계열은 bottom-up(stride가 음수)이 되는 경우도 있습니다. IMF2DBuffer::Lock2D로 scan line 0의 선두 포인터와 실제 stride를 얻어, 일단 top-down의 연속 BGRA 버퍼로 다시 채운 뒤 PNG에 넘기면 저장 측이 단순해지고 무너짐도 막을 수 있습니다.

저자 프로필

기사 저자의 프로필 페이지입니다.

Go Komura

합동회사 코무라소프트 대표

Windows 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.

블로그 목록으로 돌아가기