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가 묘하게 투명해지는 수수하게 성가신 사고가 일어납니다.
flowchart TB
accTitle: 거칠게 진행했을 때 일어나는 사고
accDescr: seek해서 1번 읽고 저장하는 것만으로는 key frame과 timestamp, stride, 상하 방향, RGB32의 4바이트째가 얽혀, 시각 어긋남·상하 반전·투명 PNG라는 사고가 일어남을 보여주는 그림.
rough1["seek해서 1번 읽고 저장"] --> tz1["시각이 조금 어긋난다"]
rough1 --> ud1["이미지가 상하로 뒤집힌다"]
rough1 --> tp1["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뿐 아니라flags와pSample도 봅니다- 출력 미디어 타입을
MFVideoFormat_RGB32로 맞추면 저장하기 쉽습니다 - 다만
RGB32의 4바이트째는 alpha라고는 할 수 없으므로 그대로 PNG로 쓰면 투명 이미지가 될 수 있습니다. 저장 전에0xFF를 넣어 불투명하게 해 두는 것이 안전합니다 - 행별
stride와 top-down / bottom-up을 거칠게 다루면 이미지가 무너지므로, 꺼낸 sample은 일단 top-down의 연속 BGRA로 정리한 뒤 PNG로 넘깁니다
요컨대 seek -> 1번 읽는다 -> 저장으로는 살짝 거칠고, seek -> timestamp를 보면서 전후 비교 -> stride를 의식해 복사 -> PNG 저장 정도까지 하면 꽤 안정적입니다.
flowchart TB
accTitle: 거친 흐름과 안정적인 흐름
accDescr: seek해서 1번 읽고 저장하는 흐름은 거칠고, seek 후 timestamp를 보면서 전후를 비교하고 stride를 의식해 복사한 뒤 PNG로 저장하는 흐름으로 하면 꽤 안정적임을 보여주는 그림.
sk1["seek"] --> cmp1["timestamp를 보면서 전후 비교"]
cmp1 --> cp1["stride를 의식해 복사"]
cp1 --> sv1["PNG 저장"]
sk1 -.->|"1번 읽고 저장은 거칠다"| ng1["어긋남·붕괴의 원인"]
그림 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가 되는 문제를 방지합니다.
flowchart LR
accTitle: MP4 정지 이미지 추출(Media Foundation)의 지식 맵
accDescr: IMFSourceReader의 SetCurrentPosition에 의한 seek가 key frame 쪽으로 쏠리는 것, GOP 길이와 ReadSample에서의 전후 비교의 관계, stride와 top-down/bottom-up 방향을 Lock2D로 흡수하는 처리, RGB32의 4바이트째를 alpha로 고정해 WIC로 PNG 저장하기까지의 관계를 보여주는 그림
video_frame_extraction["동영상 프레임 추출(지정 시각)"]
imfsourcereader["IMFSourceReader(Source Reader)"]
media_foundation["Media Foundation"]
source_reader_seek["SetCurrentPosition을 통한 seek"]
readsample["IMFSourceReader::ReadSample"]
group_of_pictures["GOP(Group of Pictures)"]
key_frame["키프레임(key frame)"]
video_stride["stride(이미지 행 바이트 수)"]
imf2dbuffer_lock2d["IMF2DBuffer::Lock2D"]
top_down_bottom_up_orientation["top-down / bottom-up(이미지 상하 방향)"]
mfvideoformat_rgb32["MFVideoFormat_RGB32"]
unintended_transparent_png["의도치 않은 투명 PNG"]
alpha_channel_fixup["alpha 0xFF 고정"]
windows_imaging_component["WIC(Windows Imaging Component)"]
null_sample_result["ReadSample의 null 샘플"]
video_frame_extraction -->|"이용한다"| imfsourcereader
imfsourcereader -->|"전제로 한다"| media_foundation
imfsourcereader -->|"이용한다"| source_reader_seek
source_reader_seek -->|"전제로 한다"| readsample
group_of_pictures -.->|"원인이 될 수 있다"| source_reader_seek
source_reader_seek -->|"이용한다"| key_frame
video_frame_extraction -->|"전제로 한다"| readsample
video_frame_extraction -->|"전제로 한다"| video_stride
video_stride -->|"이용한다"| imf2dbuffer_lock2d
video_stride -->|"전제로 한다"| top_down_bottom_up_orientation
mfvideoformat_rgb32 -.->|"원인이 될 수 있다"| unintended_transparent_png
alpha_channel_fixup -->|"방지한다"| unintended_transparent_png
video_frame_extraction -->|"이용한다"| windows_imaging_component
video_frame_extraction -->|"이용한다"| mfvideoformat_rgb32
readsample -->|"원인이 될 수 있다"| null_sample_result
그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 15건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle
2. 이 글의 전제
2.1. 대상 독자와 전제 지식
- C++로 Windows의 COM API를 호출해 본 적이 있는 분을 대상으로 합니다.
CoInitializeEx로 초기화하고 인터페이스 포인터를Release로 해제한다는 흐름을 알면 충분합니다 - 반환값인
HRESULT는SUCCEEDED/FAILED매크로로 판정합니다. 실패했을 때는 16진수 값을 그대로 읽습니다.0x8007로 시작하는 값은 Win32 오류에서 온 것으로, 하위 16 bit가 Win32 오류 코드입니다(0x80070057은E_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프레임을 얻고 싶다는 용도라면 이쪽이 꽤 명료합니다.
flowchart TB
accTitle: 이번 전제의 구도
accDescr: 로컬 MP4를 입력으로, 동기 모드의 IMFSourceReader로 지정 시각에 가장 가까운 프레임을 얻고, WIC로 PNG로 저장한다는 외부 라이브러리 없는 전제 구성을 보여주는 그림.
mp1["로컬 MP4"] --> rd1["동기 모드의 IMFSourceReader"]
rd1 --> nf1["지정 시각에 가장 가까운 프레임"]
nf1 --> png1["WIC로 PNG 저장"]
rd1 -.-> std1["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장을 얻기 쉬워집니다.
flowchart TB
accTitle: 가장 가까운 1장을 고르는 판정 규칙
accDescr: seek 후 ReadSample을 진행해 target보다 앞의 마지막 sample을 보유하고, target 이후의 최초 sample이 오면 양쪽의 차이를 비교해 target에 더 가까운 쪽을 채용하는 판정 규칙을 보여주는 그림.
adv1["seek 후 ReadSample을 진행한다"] --> bf1["target보다 앞의 마지막 sample을 보유"]
bf1 --> af1["target 이후의 최초 sample이 온다"]
af1 --> df1["양쪽과 target의 차이를 비교"]
df1 --> pk1["더 가까운 쪽을 채용"]
그림 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가지를 밟지만 않으면 구현 자체는 자연스럽게 정리됩니다.
flowchart TB
accTitle: 처리에 숨은 4가지 함정
accDescr: 처리의 겉모습은 단순하지만 seek의 정밀도, sample의 null, stride, RGB32의 4바이트째라는 4가지 작은 함정이 있고, 이를 밟지 않으면 구현은 자연스럽게 정리됨을 보여주는 그림.
lk1["겉보기는 단순한 처리"] --> t1["seek의 정밀도"]
lk1 --> t2["sample의 null"]
lk1 --> t3["stride와 방향"]
t3 -.-> t4["4바이트째 취급"]
t1 --> okf["밟지 않으면 자연스럽게 정리된다"]
t2 --> okf
t3 --> okf
그림 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초 가까이 앞선 프레임을 저장하게 됩니다.
flowchart TB
accTitle: seek 직후의 1회 읽기가 어긋나는 구조
accDescr: SetCurrentPosition은 지정 위치보다 조금 앞, 특히 key frame 쪽으로 쏠리기 때문에, 직후에 1번만 읽어 저장하면 GOP가 긴 동영상에서는 지정 시각보다 상당히 앞선 프레임을 저장하게 됨을 보여주는 그림.
sp1["SetCurrentPosition(target)"] --> kf1["key frame 쪽으로 조금 앞에 착지"]
kf1 --> one1["ReadSample을 1번만"]
one1 --> ng2["앞선 프레임을 저장하게 된다"]
ng2 -.-> gp1["GOP가 길수록 어긋남이 커진다"]
그림 6: seek는 key frame 쪽으로 쏠리므로, 1회 읽기로는 GOP 길이만큼 앞선 그림을 저장할 수 있다.
4.2. ReadSample은 성공이어도 pSample == nullptr일 수 있다
ReadSample은 S_OK여도 ppSample이 NULL일 때가 있습니다. 끝이라면 MF_SOURCE_READERF_ENDOFSTREAM, 스트림 갭이라면 MF_SOURCE_READERF_STREAMTICK 등의 flag가 반환됩니다.
HRESULT만 보고 pSample을 곧바로 참조하는 것은 위험합니다. HRESULT, flags, pSample을 3점 세트로 보는 정도가 안전합니다.
flowchart TB
accTitle: ReadSample 결과의 3점 확인
accDescr: ReadSample은 S_OK여도 sample이 NULL일 수 있으므로, HRESULT와 flags와 pSample을 3점 세트로 확인해 끝이나 스트림 갭의 flag를 처리할 필요가 있음을 보여주는 그림.
rs1["ReadSample의 결과"] --> h1["HRESULT를 본다"]
rs1 --> f1["flags를 본다"]
rs1 --> s1["pSample을 본다"]
f1 -.-> gap1["끝이나 갭의 flag가 있을 수 있다"]
s1 -.-> nl1["S_OK여도 NULL일 수 있다"]
그림 7: 성공 코드만으로 참조하지 말고 HRESULT·flags·pSample을 3점 세트로 확인한다.
4.3. stride와 상하 방향을 거칠게 다루면 이미지가 무너진다
이미지 버퍼는 width * bytesPerPixel로 일직선으로 채워져 있다고는 할 수 없습니다. 행 끝에 padding이 들어가는 경우도 있고, RGB 계열은 bottom-up이 되는 경우도 있습니다. Microsoft Learn의 Image Stride 와 Uncompressed Video Buffers 에서도 이 점은 상당히 분명하게 쓰여 있습니다.
특히 중요한 것은 다음 2가지입니다.
IMF2DBuffer::Lock2D는 scan line 0의 선두 포인터와 실제 stride를 반환한다- bottom-up 이미지에서는 stride가 음수가 될 수 있다
이번에는 Microsoft Learn의 helper 사고방식을 가져와, 최종적으로 top-down의 연속 BGRA 버퍼로 다시 채운 뒤 PNG로 넘깁니다. 여기를 먼저 정리해 두면 저장 측이 꽤 단순해집니다.
flowchart TB
accTitle: stride와 방향의 흡수
accDescr: IMF2DBuffer의 Lock2D로 scan line 0의 선두 포인터와 실제 stride를 얻고, bottom-up에서 stride가 음수인 경우도 포함해 top-down의 연속 BGRA 버퍼로 다시 채운 뒤 PNG로 넘기는 흐름을 보여주는 그림.
l2d["Lock2D로 취득"] --> sl0["scan line 0의 선두 포인터"]
l2d --> ast1["실제 stride"]
ast1 -.-> neg1["bottom-up에서는 음수가 될 수 있다"]
sl0 --> pack1["top-down의 연속 BGRA로 다시 채운다"]
ast1 --> pack1
pack1 --> sim1["저장 측이 단순해진다"]
그림 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로 채워 완전히 불투명하게 만드는 방침으로 합니다.
flowchart TB
accTitle: RGB32의 4바이트째 취급
accDescr: MFVideoFormat_RGB32는 byte 0부터 2까지가 B·G·R이고 4바이트째는 alpha라고는 할 수 없으므로, 그대로 PNG에 쓰면 투명해질 수 있고, 저장 전에 alpha를 0xFF로 채워 불투명하게 만드는 방침을 보여주는 그림.
rgb1["MFVideoFormat_RGB32 프레임"] --> b4["4바이트째는 alpha라고는 할 수 없다"]
b4 -->|"그대로 저장"| tp2["투명 PNG가 될 수 있다"]
b4 -->|"0xFF로 채운 뒤 저장"| op1["완전 불투명 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 프레임을 받는다」는 전제로 작성하기 쉬워집니다.
flowchart TB
accTitle: Reader 생성 시의 4단계
accDescr: 비디오 처리를 활성화하고, 모든 stream을 일단 off한 뒤 첫 번째 비디오 stream만 on으로 하고, 출력 type을 RGB32로 설정하는 Reader 생성 시 4단계의 순서를 보여주는 그림.
c1["비디오 처리를 활성화"] --> c2["모든 stream을 off"]
c2 --> c3["첫 번째 비디오 stream만 on"]
c3 --> c4["출력 type을 RGB32로 설정"]
c4 -.-> rdy1["뒷단은 RGB32 전제로 작성 가능"]
그림 10: Reader 생성 시의 4단계를 순서대로 마치면, 뒷단은 RGB32 프레임을 받기만 하면 된다.
5.2. seek 뒤에 timestamp를 보면서 채운다
SetCurrentPosition 뒤에 곧바로 저장하지 않습니다. ReadSample로 sample을 읽으면서 target보다 앞의 마지막 1장과 target을 넘은 최초 1장을 비교합니다.
이 한 수고로 seek의 거칢을 꽤 흡수할 수 있습니다.
sequenceDiagram
accTitle: seek 후 전후를 비교하는 대화
accDescr: 앱이 SetCurrentPosition으로 seek한 뒤, ReadSample을 반복해 timestamp를 확인하고 target을 넘은 시점에서 전후 sample을 비교해 더 가까운 쪽을 고르는 흐름을 보여주는 그림.
participant A as 앱
participant R as Source Reader
A->>R: SetCurrentPosition(target)
loop target을 넘을 때까지
A->>R: ReadSample
R-->>A: sample과 timestamp
end
A->>A: 전후 sample을 비교해 더 가까운 쪽을 채용
그림 11: seek 직후에 저장하지 않고, target을 넘을 때까지 읽어 나간 뒤 전후를 비교한다.
5.3. sample에서 top-down BGRA로 정형한다
꺼낸 sample은 그대로 PNG에 쓰지 않고, 일단 top-down의 BGRA 버퍼로 다시 채웁니다.
ConvertToContiguousBuffer로 1개의 buffer로 만든다BufferLockhelper로 scan line 0과 actual stride를 얻는다- 행별로 top-down buffer로 복사한다
- alpha를
0xFF로 한다
이렇게 하면 저장 측은 「그냥 32bpp BGRA 이미지」로 다룰 수 있습니다.
flowchart TB
accTitle: sample에서 BGRA로의 정형 절차
accDescr: ConvertToContiguousBuffer로 1개의 버퍼로 만들고, BufferLock으로 scan line 0과 실제 stride를 얻어, 행별로 top-down 버퍼로 복사하고 alpha를 0xFF로 만드는 정형 절차를 보여주는 그림.
cv1["ConvertToContiguousBuffer"] --> bl1["BufferLock으로 scan line 0과 stride"]
bl1 --> rc1["행별로 top-down으로 복사"]
rc1 --> al1["alpha를 0xFF로"]
al1 --> out1["그냥 32bpp BGRA 이미지"]
그림 12: 4단계의 정형을 거치면 저장 측은 그냥 32bpp BGRA 이미지로 다룰 수 있다.
5.4. PNG 저장은 WIC에 맡긴다
저장은 WIC의 IWICBitmapEncoder / IWICBitmapFrameEncode를 사용합니다. Media Foundation에서 프레임을 얻고 WIC로 이미지화한다는 분담입니다. 여기는 Windows 표준 API만으로 완결됩니다.
flowchart LR
accTitle: Media Foundation과 WIC의 분담
accDescr: Media Foundation이 프레임 취득을 맡고, WIC가 이미지화와 PNG 출력을 맡는다는 분담을 보여주는 그림.
mf1["Media Foundation"] -->|"프레임을 얻는다"| fr1["BGRA 프레임"]
fr1 -->|"WIC가 이미지화"| pg1["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를 다시 만들지 말고 SetCurrentPosition과 ReadSample의 반복으로 바꾸십시오. Source Reader 이외의 선택지(Media Session 등)를 포함한 Media Foundation 전체의 지도는 Media Foundation 입문 - COM 관점에서 API 이해하기 에 있습니다.
flowchart TB
accTitle: 1장 추출에서 연속 추출로의 확장 방법
accDescr: 1장만 뽑는다면 이번 구성 그대로 괜찮고, 같은 동영상에서 여러 장을 뽑을 때는 Source Reader를 다시 만들지 않고 seek와 ReadSample을 반복하는 형태로 바꾸는 확장 방법을 보여주는 그림.
q3["몇 장을 뽑는가"] -->|"1장만"| as1["이번 구성 그대로"]
q3 -->|"여러 장"| rp1["Reader를 다시 만들지 않고 seek와 읽기를 반복"]
rp1 -.-> cost1["copy 횟수의 비용도 의식한다"]
그림 14: 여러 장을 뽑도록 확장할 때는 Reader를 다시 만들지 않고 seek와 읽기의 반복으로 바꾼다.
7. 빌드와 실행 메모
이 글 마지막의 코드는 Visual Studio C++ 콘솔 앱에 1개의 .cpp로 추가하기 쉬운 형태로 되어 있습니다. 환경 자체의 전제는 2.2에 정리해 두었습니다.
7.1. 빌드 메모
알아 두면 편한 점을 들어 둡니다.
#pragma comment(lib, ...)를 넣어 두었으므로 추가 링커 설정은 기본적으로 불필요합니다wmain을 사용하므로 커맨드라인 인자는 Unicode 그대로 다룰 수 있습니다- 기본 Console App 템플릿에
pch.h나stdafx.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의 전후 비교가 작동하지 않는 것을 의심하십시오.
flowchart TB
accTitle: Requested와 Actual 차이를 보는 법
accDescr: Requested와 Actual의 차이가 프레임 간격의 절반 이내에 들어가 있으면 정상이고, 수백 밀리초 이상 떨어져 있다면 seek 후의 전후 비교가 작동하지 않는 것을 의심한다는 진단 흐름을 보여주는 그림.
dfc["Requested와 Actual의 차이를 본다"] -->|"프레임 간격의 절반 이내"| okd["전후 비교가 작동하고 있다"]
dfc -->|"수백 ms 이상"| ngd["전후 비교가 작동하지 않을 가능성"]
ngd -.-> ck2["seek 후에 1번만 읽지 않았는지 등을 확인"]
그림 15: 차이가 프레임 간격의 절반을 크게 넘는다면 전후 비교 구현을 의심한다.
동작 확인의 목표는 이 3가지입니다.
- 종료 코드가 0이고,
Saved:행에 지정한 출력 경로가 나와 있다 - 출력된 PNG를 열면 그 시각의 장면이 올바른 방향(상하가 뒤집히지 않음)으로 표시된다
- PNG의 폭·높이가 원본 동영상의 해상도와 일치하고, 배경이 비치지 않는다(4.4의 alpha 채우기가 작동하고 있다)
실패한 경우 표준 오류 출력에 Failed. HRESULT = 0x........가 출력됩니다. 값은 2.1의 요령으로 읽습니다. 지정 초 수가 동영상 길이 이상일 때는 0x80070057(E_INVALIDARG)이 됩니다.
flowchart TB
accTitle: 실행 결과 확인 흐름
accDescr: 실행이 성공하면 3행의 출력과 PNG의 방향·해상도·불투명을 확인하고, 실패하면 표준 오류 출력의 HRESULT를 읽으며, 지정 초 수가 동영상 길이 이상이면 E_INVALIDARG가 된다는 확인 흐름을 보여주는 그림.
run1["실행한다"] -->|"성공"| ok3["3행의 출력과 PNG를 확인"]
run1 -->|"실패"| er2["표준 오류의 HRESULT를 읽는다"]
er2 -.-> iv1["초 수가 동영상 길이 이상이면 E_INVALIDARG"]
그림 16: 성공하면 출력과 PNG의 3가지를 확인하고, 실패하면 HRESULT 값에서 원인을 읽는다.
8. 정리
Media Foundation으로 MP4에서 지정 시각의 정지 이미지를 꺼낼 때는 SetCurrentPosition과 ReadSample만 보고 있으면 조금 부족합니다. 실제로는,
- seek는 exact가 아니다
- frame은 timestamp를 보고 전후 비교하는 것이 좋다
ReadSample성공이어도 sample이 없을 수 있다stride와 이미지 방향을 흡수한 뒤 저장한다RGB32의 4바이트째를 alpha로 단정하지 않는다
이 정도까지 짚어 두면 꽤 사고가 나기 어려워집니다.
flowchart TB
accTitle: 짚어 둘 5가지
accDescr: seek는 exact가 아니라는 것, timestamp의 전후 비교, sample이 없는 경우의 확인, stride와 방향의 흡수, RGB32의 4바이트째를 단정하지 않는 것의 5가지를 짚어 두면 사고가 나기 어려워짐을 보여주는 그림.
k1["seek는 exact가 아니다"] --> k2["timestamp를 전후 비교"]
k2 --> k3["성공이어도 sample 없음을 확인"]
k3 --> k4["stride와 방향을 흡수"]
k4 --> k5["4바이트째를 단정하지 않는다"]
k5 --> safe2["사고가 나기 어려운 구현"]
그림 17: 5가지 짚을 점이 갖추어지면, 지정 시각의 정지 이미지 추출은 꽤 사고가 나기 어려워진다.
이번 샘플은 1장을 제대로 뽑는 것에 좁힌 최소 구성입니다. 썸네일 생성, 감시 영상의 대표 frame 저장, 검사 로그의 증거 출력 부근에는 그대로 가져갈 수 있는 형태로 되어 있습니다.
9. 참고 자료
- 이 글의 샘플 코드 일습: media-foundation-extract-still-image-from-mp4-at-specific-time - komurasoft-blog-samples (GitHub)
- Microsoft Learn: Using the Source Reader to Process Media Data
- Microsoft Learn:
IMFSourceReader::SetCurrentPosition - Microsoft Learn:
IMFSourceReader::ReadSample - Microsoft Learn:
IMFSourceReader::SetCurrentMediaType - Microsoft Learn:
IMF2DBuffer - Microsoft Learn:
IMF2DBuffer::Lock2D - Microsoft Learn: Uncompressed Video Buffers
- Microsoft Learn: Image Stride
- Microsoft Learn: MF_MT_FRAME_SIZE attribute
- Microsoft Learn: MF_MT_DEFAULT_STRIDE attribute
- Microsoft Learn: Native pixel formats overview (WIC)
- Microsoft Learn: Uncompressed RGB Video Subtypes
10. .cpp에 그대로 붙일 수 있는 전체 코드
아래 1블록이 그대로 Visual Studio C++ 콘솔 앱 프로젝트로 가져갈 것을 전제로 한 코드입니다. 커맨드라인 인자는 input.mp4, seconds, output.png 순서입니다. 1파일 완결 구성으로 되어 있어 프로젝트에 붙이기 쉬운 형태입니다.
긴 1블록이므로, 어떤 함수가 본문의 어디에 대응하는지를 먼저 두어 둡니다. 읽을 때도, 동작하지 않을 때의 조사에서도, 이 대응표부터 들어가는 것이 빠릅니다.
| 코드 중의 함수·클래스 | 대응하는 본문 | 역할과 대처하고 있는 함정 |
|---|---|---|
MediaFoundationScope |
5.1 | CoInitializeEx와 MFStartup의 초기화·뒷정리를 정리한다 |
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장을 고른다. flags와 pSample의 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,
¤tTimestamp,
&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;
}
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
Media Foundation으로 MP4 동영상의 각 프레임에 이미지와 문자를 구워 넣는 방법 - Source Reader / 드로잉 / 색 변환 / Sink Writer 정리와 .cpp에 그대로 붙일 수 있는 1파일 완결판
MP4 동영상의 각 프레임에 이미지와 문자를 구워 넣어 새 MP4를 만드는 방법을, Source Reader 디코드 -> GDI+ 합성 -> NV12 색 변환 -> Sink Writer 재인코딩의 흐름과, Visual Studio C++에 그대로...
Media Foundation에서 YUV 프레임을 RGB로 변환하는 방법 - Source Reader의 자동 변환과 직접 변환을 원리부터 정리
Media Foundation에서 NV12·YUY2 같은 YUV 프레임을 RGB로 옮기는 두 가지 길을 정리합니다. Source Reader의 자동 RGB32 변환과 직접 변환을 색공간·서브샘플링·stride 관점에서 비교하고 BT.601/709...
Media Foundation이란 무엇인가 - COM과 Windows 미디어 API의 얼굴이 보이는 이유
Media Foundation이 무엇인지를 COM, HRESULT, IMFSourceReader, MFT 등 Windows 미디어 API의 기본 용어와 함께, 가장 먼저 짚어야 할 순서로 정리합니다.
Win32 스레드 풀 API ── CreateThreadpoolWork로 「스레드를 만들지 않는」병렬 처리
네이티브 코드에서 CreateThread를 여기저기 만들고 있지는 않은가요. Vista에서 개편된 Win32 스레드 풀 API의 work·timer·wait·io 네 객체, 클린업 그룹, 콜백에서 해서는 안 되는 일까지 1차 정보를 바탕으로 해설...
네임드 파이프의 실무 ── Windows 프로세스 간 통신의 정석을 설계부터 보안까지
Windows 프로세스 간 통신의 정석인 네임드 파이프를 실무 관점에서 정리합니다. 바이트/메시지 모드 선택, 여러 클라이언트를 받는 서버 설계, ACL과 위장 보안, .NET NamedPipeStream까지 1차 정보를 바탕으로 설명합니다.
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
상주 처리, 장비 연동, 운영 로그, 유지 보수 가능한 구조가 필요한 Windows 데스크톱 애플리케이션을 지원합니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- 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에 넘기면 저장 측이 단순해지고 무너짐도 막을 수 있습니다.