Media Foundation으로 MP4의 지정 시각에서 정지 이미지를 잘라내는 방법
· 업데이트: · Go Komura · 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가 이상하게 투명해지는, 사소하지만 거북한 사고가 납니다.
flowchart TB
accTitle: 대충 진행했을 때 일어나는 사고
accDescr: seek한 뒤 한 번 읽고 저장하기만 하는 구현에서는 key frame이나 timestamp, stride, 상하 방향, RGB32의 네 번째 바이트가 얽혀 시각 어긋남·상하 반전·투명 PNG라는 사고가 난다는 점을 보이는 그림.
rough1["seek한 뒤 한 번 읽고 저장"] --> 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의 네 번째 바이트는 alpha라고 단정할 수 없어서, 그대로 PNG에 쓰면 투명 이미지가 될 수 있습니다. 저장 전에0xFF를 넣어 불투명하게 만드는 편이 안전합니다 - 행마다의
stride와 top-down / bottom-up을 대충 다루면 이미지가 깨지므로, 꺼낸 sample은 일단 top-down의 연속 BGRA로 맞춘 뒤 PNG에 넘깁니다
결국 seek -> 한 번 읽기 -> 저장은 조금 거칠고, seek -> timestamp를 보며 앞뒤 비교 -> stride를 의식해 복사 -> PNG 저장 정도까지 하면 꽤 안정적입니다.
flowchart TB
accTitle: 거친 흐름과 안정되는 흐름
accDescr: seek한 뒤 한 번 읽고 저장하는 흐름은 거칠고, seek 뒤에 timestamp를 보며 앞뒤를 비교한 다음 stride를 의식해 복사하고 PNG로 저장하는 흐름으로 바꾸면 꽤 안정된다는 점을 보이는 그림.
sk1["seek"] --> cmp1["timestamp를 보며 앞뒤 비교"]
cmp1 --> cp1["stride를 의식해 복사"]
cp1 --> sv1["PNG 저장"]
sk1 -.->|"한 번 읽고 저장은 거칠다"| 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 오류에서 온 것이고, 하위 16bit가 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를 전제로 한다
재생, 음성 동기, 시크 바, 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 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, 네 번째 바이트 처리에 각각 작은 함정이 있습니다. 이 네 가지를 밟지 않으면 구현 자체는 수월하게 정리됩니다.
flowchart TB
accTitle: 처리에 숨어 있는 네 가지 함정
accDescr: 처리는 겉보기에는 단순하지만 seek 정밀도, sample의 null, stride, RGB32의 네 번째 바이트라는 네 가지 작은 함정이 있고, 이를 밟지 않으면 구현이 수월하게 정리된다는 점을 보이는 그림.
lk1["겉보기에는 단순한 처리"] --> t1["seek 정밀도"]
lk1 --> t2["sample의 null"]
lk1 --> t3["stride와 방향"]
t3 -.-> t4["네 번째 바이트 처리"]
t1 --> okf["밟지 않으면 수월하게 정리된다"]
t2 --> okf
t3 --> okf
그림 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초 가까이 앞의 프레임을 저장하게 됩니다.
flowchart TB
accTitle: seek 직후 한 번 읽기가 어긋나는 구조
accDescr: SetCurrentPosition은 지정 위치보다 조금 앞, 특히 key frame 쪽으로 붙으므로, 직후에 한 번만 읽고 저장하면 GOP가 긴 동영상에서는 지정 시각보다 꽤 앞의 프레임을 저장하게 된다는 점을 보이는 그림.
sp1["SetCurrentPosition(target)"] --> kf1["key frame 쪽의 조금 앞에 착지"]
kf1 --> one1["ReadSample을 한 번만"]
one1 --> ng2["앞쪽 프레임을 저장해 버린다"]
ng2 -.-> gp1["GOP가 길수록 어긋남이 크다"]
그림 6: seek는 key frame 쪽으로 붙으므로, 한 번 읽기만으로는 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을 세 가지 모두 보는 편이 안전합니다.
flowchart TB
accTitle: ReadSample 결과의 세 가지 확인
accDescr: ReadSample은 S_OK여도 샘플이 NULL일 수 있으므로 HRESULT와 flags와 pSample을 세 가지 모두 확인하고, 끝이나 스트림 갭 flag를 처리해야 한다는 점을 보이는 그림.
rs1["ReadSample의 결과"] --> h1["HRESULT를 본다"]
rs1 --> f1["flags를 본다"]
rs1 --> s1["pSample을 본다"]
f1 -.-> gap1["끝이나 갭의 flag가 있을 수 있다"]
s1 -.-> nl1["S_OK여도 NULL일 수 있다"]
그림 7: 성공 코드만으로 참조하지 않고, HRESULT·flags·pSample을 세 가지 모두 확인한다.
4.3. stride와 상하 방향을 대충 다루면 이미지가 깨진다
이미지 버퍼는 width * bytesPerPixel로 한 줄로 붙어 있다고 할 수 없습니다. 행 끝에 padding이 들어가기도 하고, RGB 계열은 bottom-up이 되기도 합니다. Microsoft Learn의 Image Stride와 Uncompressed Video Buffers에도 이 점은 꽤 분명히 적혀 있습니다.
특히 중요한 것은 다음 두 가지입니다.
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의 네 번째 바이트를 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로 채워 완전 불투명으로 만드는 방침입니다.
flowchart TB
accTitle: RGB32의 네 번째 바이트 처리
accDescr: MFVideoFormat_RGB32는 byte 0부터 2가 B·G·R이고 네 번째 바이트는 alpha라고 단정할 수 없어 그대로 PNG에 쓰면 투명해질 수 있으므로, 저장 전에 alpha를 0xFF로 채워 불투명하게 하는 방침을 보이는 그림.
rgb1["MFVideoFormat_RGB32의 프레임"] --> b4["네 번째 바이트는 alpha라고 단정할 수 없다"]
b4 -->|"그대로 저장"| tp2["투명 PNG가 될 수 있다"]
b4 -->|"0xFF로 채운 뒤 저장"| op1["완전 불투명의 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 프레임을 받는다”는 전제로 쓰기 쉬워집니다.
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로 버퍼를 하나로 만든다BufferLockhelper로 scan line 0과 actual stride를 얻는다- 행마다 top-down buffer로 복사한다
- alpha를
0xFF로 만든다
이렇게 하면 저장 쪽은 “그냥 32bpp BGRA 이미지”로 다룰 수 있습니다.
flowchart TB
accTitle: sample에서 BGRA로의 정리 절차
accDescr: ConvertToContiguousBuffer로 버퍼를 하나로 만들고, 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 직후 한 번으로 정하지 않는다 |
지정 시각보다 꽤 앞의 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를 다시 만들지 않고 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++ 콘솔 앱에 .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 뒤에 한 번만 읽었는지 등 4.1의 앞뒤 비교가 적용되지 않은 것을 의심합니다.
flowchart TB
accTitle: Requested와 Actual 차의 보는 법
accDescr: Requested와 Actual의 차가 프레임 간격의 절반 이내이면 정상이고, 수백 밀리초 이상 떨어져 있으면 seek 뒤의 앞뒤 비교가 적용되지 않은 것을 의심한다는 진단 흐름을 보이는 그림.
dfc["Requested와 Actual의 차를 본다"] -->|"프레임 간격의 절반 이내"| okd["앞뒤 비교가 적용되고 있다"]
dfc -->|"수백 ms 이상"| ngd["앞뒤 비교가 적용되지 않은 의심"]
ngd -.-> ck2["seek 뒤에 한 번만 읽었는지 등을 확인"]
그림 15: 차가 프레임 간격의 절반을 크게 넘으면, 앞뒤 비교 구현을 의심한다.
동작 확인의 목표는 다음 세 가지입니다.
- 종료 코드가 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의 세 가지를 확인하고, 실패이면 HRESULT 값에서 원인을 읽는다.
8. 정리
Media Foundation으로 MP4에서 지정 시각의 정지 이미지를 꺼낼 때는 SetCurrentPosition과 ReadSample만 보고 있으면 조금 부족합니다. 실제로는,
- seek는 exact가 아니다
- frame은 timestamp를 보고 앞뒤를 비교하는 편이 좋다
ReadSample이 성공해도 sample이 없을 수 있다stride와 이미지 방향을 흡수한 뒤 저장한다RGB32의 네 번째 바이트는 alpha로 단정하지 않는다
이 정도까지 짚어두면 사고가 꽤 줄어듭니다.
flowchart TB
accTitle: 짚어 둘 다섯 가지
accDescr: seek는 exact가 아니라는 점, timestamp의 앞뒤 비교, sample이 없는 경우의 확인, stride와 방향의 흡수, RGB32의 네 번째 바이트를 단정하지 않는다는 다섯 가지를 짚으면 사고가 줄어든다는 점을 보이는 그림.
k1["seek는 exact가 아니다"] --> k2["timestamp를 앞뒤 비교"]
k2 --> k3["성공해도 sample 없음을 확인"]
k3 --> k4["stride와 방향을 흡수"]
k4 --> k5["네 번째 바이트를 단정하지 않는다"]
k5 --> safe2["사고 나기 어려운 구현"]
그림 17: 다섯 가지 짚을 곳이 갖춰지면, 지정 시각의 정지 이미지 추출은 사고가 꽤 줄어든다.
이번 샘플은 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 버퍼에 다시 채우고, 네 번째 바이트를 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의 네 번째 바이트는 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에 이미지와 텍스트를 오버레이하는 방법
Media Foundation으로 MP4 각 프레임에 이미지와 텍스트를 합성해 새 MP4를 만드는 생각을, Source Reader, 그리기, 색 변환, Sink Writer의 역할 분담과 1파일 완결 C++ 샘플로 정리합니다.
Media Foundation에서 YUV를 RGB로 변환하는 방법
Media Foundation에서 YUV 프레임을 RGB로 변환하는 방법을 Source Reader의 자동 변환과 NV12/YUY2 직접 변환, stride, 색공간 관점에서 정리합니다.
Media Foundation 입문 - COM 관점으로 API 이해하기
Media Foundation이 무엇인지, COM, HRESULT, IMFSourceReader, MFT 등 Windows 미디어 API의 기본 용어와 함께, 먼저 짚어야 할 순서로 정리합니다.
Win32 스레드 풀 API ── CreateThreadpoolWork로 「스레드를 만들지 않는」 병렬 처리
네이티브 코드에서 CreateThread를 마구 늘리고 있지는 않은가요. Vista에서 개편된 Win32 스레드 풀 API의 work·timer·wait·io 네 객체, 클린업 그룹, 콜백에서 해서는 안 되는 일까지 1차 정보를 바탕으로 설명합니다.
Named Pipe 실무 ── Windows 프로세스 간 통신의 정석을 설계부터 보안까지
Windows의 프로세스 간 통신의 정석인 Named Pipe를 실무 관점에서 해설합니다. 바이트/메시지 모드 선택, 여러 클라이언트를 처리하는 서버 설계, ACL과 impersonation 보안, .NET NamedPipeStream까지 1차 ...
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
Media Foundation, Source Reader, WIC를 써서 동영상에서 정지 이미지를 꺼내는 내용이므로, Windows 앱 개발의 구현 주제에 가깝습니다.
기술 상담 & 설계 리뷰
seek 정밀도, 버퍼 형식, stride, 이미지 방향 등을 구현 전에 정리하고 싶은 경우에는 기술 상담·설계 리뷰로 방향 정리부터 들어갑니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- 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에 넘기면 저장 쪽이 단순해지고 깨짐도 막을 수 있습니다.