Media Foundation으로 MP4의 지정 시각에서 정지 이미지를 잘라내는 방법

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

수정 이력(7건, 최종 수정 2026년 09월 03일)

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

permalink·저자 표기·지식 맵 래퍼·깨진 내부 링크 등 CI가 지적한 표시용 수정을 반영했습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
일본어 원문의 완역으로 다시 번역했습니다. 기존 한국어판은 원문의 일부만 옮긴 축약본이라 절, 표, Mermaid 그림, 그림 설명, FAQ가 빠져 있었습니다. 일본어 원문에 맞춰 이들을 모두 복원했고, 기술적인 주장은 일본어판과 같습니다. 수정 전 버전 보기 (DOI: 10.5281/zenodo.21635134)
seek의 거칠기와 앞뒤 비교, ReadSample의 세 가지 확인, stride와 alpha 정리, WIC 저장까지의 흐름을 그림으로도 따라갈 수 있도록 Mermaid 그림을 17개 추가했습니다(본문 500~750자당 1그림 규약에 맞춘 것입니다). 본문 문장은 바꾸지 않았습니다.
글 맨 앞에 "이 글의 지식 맵" 절을 추가했습니다. 본문에서 다루는 개념과 그 관계를 요약·그림·상세 페이지 링크로 정리한 것입니다. 본문의 주장은 바꾸지 않았습니다.
외부 리뷰(1283건)에 대응해 본문을 업데이트했습니다. 개별 변경 내용은 아래 이력을 참조합니다.
대상 독자와 개발 환경, 입력과 출력의 전제를 나눠 정리했습니다. 코드의 함수와 본문의 대응표, 빌드와 실행 방법과 성공했을 때 나오는 것, 용어표를 추가했습니다. 실행 결과의 구체값은 실측하지 않았으므로 쓰지 않고, 차가 프레임 간격의 절반 이내에 들어간다는 글 안의 논리에서 도출할 수 있는 설명으로 했습니다.
본문의 관련 글 링크 문구가 링크 대상의 현재 제목과 어긋나 있던 것을 실제 제목에 맞췄습니다. 본문 내용은 바꾸지 않았습니다.
최초 공개
이 글을 인용하기(DOI: 10.5281/zenodo.21635133)

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

Go Komura (2026). 「Media Foundation으로 MP4의 지정 시각에서 정지 이미지를 잘라내는 방법」. 합동회사 코무라소프트. https://doi.org/10.5281/zenodo.21635133 https://comcomponent.com/ko/blog/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을 한 번 호출하면 끝나는 것처럼 보이지만, 실제로는 key frame, timestamp, stride, 이미지의 상하 방향, RGB32의 네 번째 바이트 등이 얽힙니다. 대충 진행하면 시각이 조금 어긋나거나, 이미지가 위아래로 뒤집히거나, PNG가 이상하게 투명해지는, 사소하지만 거북한 사고가 납니다.

대충 진행했을 때 일어나는 사고seek한 뒤 한 번 읽고 저장하기만 하는 구현에서는 key frame이나 timestamp, stride, 상하 방향, RGB32의 네 번째 바이트가 얽혀 시각 어긋남·상하 반전·투명 PNG라는 사고가 난다는 점을 보이는 그림.seek한 뒤 한 번 읽고 저장시각이 조금 어긋난다이미지가 위아래로 뒤집힌다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의 네 번째 바이트는 alpha라고 단정할 수 없어서, 그대로 PNG에 쓰면 투명 이미지가 될 수 있습니다. 저장 전에 0xFF를 넣어 불투명하게 만드는 편이 안전합니다
  • 행마다의 stride와 top-down / bottom-up을 대충 다루면 이미지가 깨지므로, 꺼낸 sample은 일단 top-down의 연속 BGRA로 맞춘 뒤 PNG에 넘깁니다

결국 seek -> 한 번 읽기 -> 저장은 조금 거칠고, seek -> timestamp를 보며 앞뒤 비교 -> stride를 의식해 복사 -> PNG 저장 정도까지 하면 꽤 안정적입니다.

거친 흐름과 안정되는 흐름seek한 뒤 한 번 읽고 저장하는 흐름은 거칠고, seek 뒤에 timestamp를 보며 앞뒤를 비교한 다음 stride를 의식해 복사하고 PNG로 저장하는 흐름으로 바꾸면 꽤 안정된다는 점을 보이는 그림.한 번 읽고 저장은 거칠다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 오류에서 온 것이고, 하위 16bit가 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를 전제로 한다

재생, 음성 동기, 시크 바, 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 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, 네 번째 바이트 처리에 각각 작은 함정이 있습니다. 이 네 가지를 밟지 않으면 구현 자체는 수월하게 정리됩니다.

처리에 숨어 있는 네 가지 함정처리는 겉보기에는 단순하지만 seek 정밀도, sample의 null, stride, RGB32의 네 번째 바이트라는 네 가지 작은 함정이 있고, 이를 밟지 않으면 구현이 수월하게 정리된다는 점을 보이는 그림.겉보기에는 단순한 처리seek 정밀도sample의 nullstride와 방향네 번째 바이트 처리밟지 않으면 수월하게 정리된다

그림 5: 단순해 보이는 흐름 곳곳에 네 가지 함정이 있고, 거기만 의식하면 구현은 수월하게 정리된다.

4. 먼저 짚어 두는 함정

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

용어 의미
key frame(키 프레임) 앞뒤 프레임을 참조하지 않고 그 단독으로 디코드할 수 있는 프레임입니다. H.264에서는 IDR 픽처 등이 해당합니다
GOP(Group of Pictures) 어떤 key frame부터 다음 key frame 직전까지의 프레임 묶음입니다. GOP가 길수록 key frame 간격이 벌어지므로, seek 뒤의 위치가 지정 시각에서 멀어지기 쉽습니다
stride 이미지 버퍼에서 한 행이 차지하는 바이트 수입니다. 너비 × 화소 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 한 번
  • 그 frame을 저장

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

seek 직후 한 번 읽기가 어긋나는 구조SetCurrentPosition은 지정 위치보다 조금 앞, 특히 key frame 쪽으로 붙으므로, 직후에 한 번만 읽고 저장하면 GOP가 긴 동영상에서는 지정 시각보다 꽤 앞의 프레임을 저장하게 된다는 점을 보이는 그림.SetCurrentPosition(target)key frame 쪽의 조금 앞에 착지ReadSample을 한 번만앞쪽 프레임을 저장해 버린다GOP가 길수록 어긋남이 크다

그림 6: seek는 key frame 쪽으로 붙으므로, 한 번 읽기만으로는 GOP 길이만큼 앞의 그림을 저장할 수 있다.

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

ReadSampleS_OK여도 ppSampleNULL인 경우가 있습니다. 끝이면 MF_SOURCE_READERF_ENDOFSTREAM, 스트림 갭이면 MF_SOURCE_READERF_STREAMTICK 같은 flag가 반환됩니다.

HRESULT만 보고 pSample을 바로 참조하면 위험합니다. HRESULT, flags, pSample을 세 가지 모두 보는 편이 안전합니다.

ReadSample 결과의 세 가지 확인ReadSample은 S_OK여도 샘플이 NULL일 수 있으므로 HRESULT와 flags와 pSample을 세 가지 모두 확인하고, 끝이나 스트림 갭 flag를 처리해야 한다는 점을 보이는 그림.ReadSample의 결과HRESULT를 본다flags를 본다pSample을 본다끝이나 갭의 flag가 있을 수 있다S_OK여도 NULL일 수 있다

그림 7: 성공 코드만으로 참조하지 않고, HRESULT·flags·pSample을 세 가지 모두 확인한다.

4.3. stride와 상하 방향을 대충 다루면 이미지가 깨진다

이미지 버퍼는 width * bytesPerPixel로 한 줄로 붙어 있다고 할 수 없습니다. 행 끝에 padding이 들어가기도 하고, RGB 계열은 bottom-up이 되기도 합니다. Microsoft Learn의 Image StrideUncompressed Video Buffers에도 이 점은 꽤 분명히 적혀 있습니다.

특히 중요한 것은 다음 두 가지입니다.

  • 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의 네 번째 바이트를 alpha로 단정하지 않는다

MFVideoFormat_RGB32는 이름에서 풍기는 인상과 달리, PNG에 그대로 넘길 수 있는 “깨끗한 RGBA”가 아닙니다. Windows의 32bit RGB는 byte 0, 1, 2가 B, G, R이고, byte 3은 alpha일 수도 ignore일 수도 있습니다. ARGB32가 아니라는 점이 중요합니다.

여기를 GUID_WICPixelFormat32bppBGRA라고 믿고 그대로 저장하면, 네 번째 바이트에 0이 들어 있어 이미지가 이상하게 투명해질 수 있습니다. 이번에는 저장 전에 alpha를 0xFF로 채워 완전 불투명으로 만드는 방침입니다.

RGB32의 네 번째 바이트 처리MFVideoFormat_RGB32는 byte 0부터 2가 B·G·R이고 네 번째 바이트는 alpha라고 단정할 수 없어 그대로 PNG에 쓰면 투명해질 수 있으므로, 저장 전에 alpha를 0xFF로 채워 불투명하게 하는 방침을 보이는 그림.그대로 저장0xFF로 채운 뒤 저장MFVideoFormat_RGB32의 프레임네 번째 바이트는 alpha라고 단정할 수 없다투명 PNG가 될 수 있다완전 불투명의 PNG

그림 9: 네 번째 바이트를 단정하지 않고, 저장 전에 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 프레임을 받는다”는 전제로 쓰기 쉬워집니다.

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로 버퍼를 하나로 만든다
  • BufferLock helper로 scan line 0과 actual stride를 얻는다
  • 행마다 top-down buffer로 복사한다
  • alpha를 0xFF로 만든다

이렇게 하면 저장 쪽은 “그냥 32bpp BGRA 이미지”로 다룰 수 있습니다.

sample에서 BGRA로의 정리 절차ConvertToContiguousBuffer로 버퍼를 하나로 만들고, 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 직후 한 번으로 정하지 않는다 지정 시각보다 꽤 앞의 frame을 저장한다
sample의 NULL HRESULT, flags, pSample을 모두 본다 끝이나 stream tick에서 null dereference가 난다
stride actual stride와 상하 방향을 흡수한다 이미지가 깨지거나 위아래가 뒤집힌다
RGB32의 네 번째 바이트 alpha를 0xFF로 만든다 투명 PNG가 된다
시각 범위 0 <= target < duration을 지킨다 끝 근처에서 의도하지 않은 동작이 된다
연속 추출 Reader를 다시 만들지 않고 seek를 반복한다 쓸데없이 느리다
copy 횟수 대량 처리에서는 ConvertToContiguousBuffer의 비용을 의식한다 CPU와 메모리 대역을 더 쓴다
포맷 변화 도중에 해상도가 바뀌는 특수 동영상은 다른 설계로 한다 너비·높이 전제가 깨진다

이 가운데 “연속 추출”과 “copy 횟수” 두 줄만은, 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++ 콘솔 앱에 .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 뒤에 한 번만 읽었는지 등 4.1의 앞뒤 비교가 적용되지 않은 것을 의심합니다.

Requested와 Actual 차의 보는 법Requested와 Actual의 차가 프레임 간격의 절반 이내이면 정상이고, 수백 밀리초 이상 떨어져 있으면 seek 뒤의 앞뒤 비교가 적용되지 않은 것을 의심한다는 진단 흐름을 보이는 그림.프레임 간격의 절반 이내수백 ms 이상Requested와 Actual의 차를 본다앞뒤 비교가 적용되고 있다앞뒤 비교가 적용되지 않은 의심seek 뒤에 한 번만 읽었는지 등을 확인

그림 15: 차가 프레임 간격의 절반을 크게 넘으면, 앞뒤 비교 구현을 의심한다.

동작 확인의 목표는 다음 세 가지입니다.

  • 종료 코드가 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의 세 가지를 확인하고, 실패이면 HRESULT 값에서 원인을 읽는다.

8. 정리

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

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

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

짚어 둘 다섯 가지seek는 exact가 아니라는 점, timestamp의 앞뒤 비교, sample이 없는 경우의 확인, stride와 방향의 흡수, RGB32의 네 번째 바이트를 단정하지 않는다는 다섯 가지를 짚으면 사고가 줄어든다는 점을 보이는 그림.seek는 exact가 아니다timestamp를 앞뒤 비교성공해도 sample 없음을 확인stride와 방향을 흡수네 번째 바이트를 단정하지 않는다사고 나기 어려운 구현

그림 17: 다섯 가지 짚을 곳이 갖춰지면, 지정 시각의 정지 이미지 추출은 사고가 꽤 줄어든다.

이번 샘플은 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 버퍼에 다시 채우고, 네 번째 바이트를 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의 네 번째 바이트는 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에서 지정 시각의 정지 이미지를 꺼내려면 무엇을 쓰면 되나요?
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의 네 번째 바이트가 alpha라고 단정할 수 없기 때문입니다. Windows의 32bit RGB는 byte 0, 1, 2가 B, G, R이고, byte 3은 alpha일 수도 ignore일 수도 있습니다(ARGB32가 아닙니다). 그대로 PNG에 쓰면 투명 이미지가 될 수 있으므로, 저장 전에 네 번째 바이트에 0xFF를 넣어 불투명하게 만드는 편이 안전합니다.
이미지가 깨지거나 위아래가 뒤집히는 원인은 무엇인가요?
stride와 상하 방향 처리입니다. 이미지 버퍼는 width×bytesPerPixel로 한 줄로 붙어 있다고 할 수 없고, 행 끝에 padding이 들어가는 경우가 있으며, RGB 계열은 bottom-up(stride가 음수)이 되기도 합니다. IMF2DBuffer::Lock2D로 scan line 0의 시작 포인터와 실제 stride를 구한 뒤, 일단 top-down의 연속 BGRA 버퍼로 다시 채워 PNG에 넘기면 저장 쪽이 단순해지고 깨짐도 막을 수 있습니다.

저자 프로필

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

Go Komura

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

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

블로그 목록으로 돌아가기