브라우저에서 비디오 리먹스: fMP4, ISOBMFF, 그리고 트랜스코딩하지 않는 이유
"리먹스"와 "트랜스코딩"을 혼용하는 사람이 많다. 이 글의 전부다. 이 둘은 완전히 다른 작업——리먹스는 빠르고 트랜스코딩은 느리고, 브라우저에서는 리먹스가 90%의 문제를 해결하며 트랜스코딩은 보통 피해야 할 느긋한 일이다.
이 글은 MP4 컨테이너가 내부에서 어떻게 생겼는지, 분할화 MP4(fMP4)가 왜 존재하고 HLS/DASH 스트림에서 그냥 붙여서 하나의 파일로 만드는 게 가능한지, 그리고 FFmpeg WASM은 정확히 언제 필요한지를 다룬다.
리먹스 vs. 트랜스코딩
트랜스코딩은 인코딩된 비디오/오디오 스트림을 디코딩해서 새로운 codec으로 재인코딩. H.264 비디오를 VP9로 바꾸면 트랜스코딩. 1080p 비디오를 720p로 스케일하면 트랜스코딩. 매 프레임을 디코딩하고 새로 인코딩하므로 느리다——보통 실시간 이상, 심하면 그 이상.
리먹스는 컨테이너만 바꾼다. 인코딩된 비디오/오디오 데이터는 그대로, 다른 패키징으로 옮긴다. TS의 H.264 스트림을 MP4의 H.264 스트림으로 바꾸면 리먹스. 작업은:
- 날것의 NAL unit(인코딩된 H.264 데이터)을 TS 패킷에서 꺼낸다
- TS 타임스탬프를 MP4 타임스탬프로 변환
- MP4 박스 구조를 빌드하고 그 안에 NAL unit을 넣는다
비디오 데이터를 디코딩하지 않는다. 그래서 거의 순간——보통 동일한 codec이라면 1분 비디오를 수초로 끝낸다.
핵심 통찰: HLS와 DASH 스트림은 이미 비디오가 H.264나 H.265로 인코딩되어 있다. MP4 컨테이너를 원하면? 리먹스하면 된다. 트랜스코딩은 필요없다.
ISOBMFF 기본
MP4 파일 포맷은 ISO Base Media File Format(ISOBMFF)에 정의. 모든 것은 "박스"——4바이트 크기 + 4바이트 타입 + 데이터——의 중첩.
전형적인 단일 파일 MP4(프로그레시브, 스트리밍이 아닌):
ftyp # 파일 타입 식별자
moov # 메타데이터 컨테이너
mvhd # 전체 헤더 (타임스케일, 길이)
trak # 트랙 1 (비디오)
tkhd # 트랙 헤더
mdia # 미디어 정보
mdhd
hdlr
minf
stbl # 샘플 테이블
stsd # 샘플 설명 (codec 파라미터)
stts # 디코딩-표시 시간 변환
stsz # 프레임 크기
stsc # 청크-샘플 매핑
stco # 청크 오프셋
trak # 트랙 2 (오디오)
...
mdat # 실제 미디어 데이터
stbl 안의 박스가 핵심: 비디오/오디오 데이터가 mdat에 어디 있는지, 어느 프레임이 몇 바이트인지, DTS/PTS가 어떻게 매핑되는지를 모두 알려준다.
스트리밍 문제: 이 구조는 mdat의 모든 바이트 오프셋을 stco가 알아야 한다고 가정. 파일을 스트리밍하면? 아직 존재하지 않는 프레임의 오프셋을 쓸 수 없다. 이것이 "MP4는 스트리밍을 못 한다"는 말의 진짜 의미——프로그레시브 MP4는 완전한 파일이 있어야 올바르고, 파일 생성 중엔 메타데이터를 쓸 수 없다.
fMP4: 스트리밍 MP4
분할화 MP4(fMP4)는 메타데이터를 분산시킨다. 파일의 시작이나 각 세그먼트마다 메타데이터가 있어서 스트리밍을 가능하게 한다.
fMP4 스트림은 2가지 종류의 파일로 구성:
- 초기화 세그먼트(init segment)——
ftyp+moov박스. 모든 트랙 메타데이터, codec 파라미터, 타임스케일 포함. 미디어 데이터 없음. 보통 몇 KB. - 미디어 세그먼트(media segment)——
moof+mdat. 이 세그먼트의 프레임을 위한 메타데이터 포함. 각 세그먼트는 자기 자신만으로 디코딩 가능.
init.mp4:
ftyp
moov
mvhd, trak, trak, ...
segment-1.m4s:
styp # 세그먼트 타입
moof # 무비 프래그먼트 헤더
mfhd # 프래그먼트 시퀀스 번호
traf # 트랙 프래그먼트
tfhd # 트랙 헤더
tfdt # 프래그먼트 디코딩 시간
trun # 트랙 런 (이 세그먼트의 샘플)
mdat # 실제 미디어 데이터
핵심: 각 moof 박스는 그 세그먼트 내 프레임의 오프셋과 크기를 모두 알려준다. init을 갖고 있으면 각 세그먼트는 독립적으로 디코딩 가능.
왜 fMP4 세그먼트를 그냥 붙이면 되는가
DASH나 HLS-fMP4 스트림을 다운로드할 때, 받는 건:
init.mp4(또는init.m4s)1.m4s,2.m4s,3.m4s…… N개의 미디어 세그먼트
재생 가능한 MP4 파일은 init + 모든 미디어 세그먼트를 순서대로 결합하면 된다:
async function mergeFmp4(initUrl, segmentUrls) {
const init = new Uint8Array(await (await fetch(initUrl)).arrayBuffer())
const chunks = [init]
for (const url of segmentUrls) {
chunks.push(new Uint8Array(await (await fetch(url)).arrayBuffer()))
}
const total = chunks.reduce((s, c) => s + c.byteLength, 0)
const out = new Uint8Array(total)
let offset = 0
for (const chunk of chunks) {
out.set(chunk, offset)
offset += chunk.byteLength
}
return new Blob([out], { type: 'video/mp4' })
}
이게 진짜로 동작하는 이유:
- init은
moov에 트랙 메타데이터를 갖고 있음 - 각
moof는 그 세그먼트의 샘플을 가리키는trun을 가짐 trun의 오프셋은 각 세그먼트의moof에 대해 상대적——그래서 init을 파일 앞에 놓고 세그먼트를 순서대로 놓으면, 전체 파일의 오프셋은 정확히 맞는다(이래서 작동)tfdt는 각 세그먼트의 시작 디코딩 시간을 준다. 즉, 세그먼트는 순서대로 놓기만 하면 됨
이게 DASH/HLS-fMP4 다운로드가 거의 무료인 이유. 세그먼트를 페치하고 Blob로 합치고 끝. FFmpeg 불필요.
이게 FlowPick의 기본 경로——DASH 스트림의 90%가 fMP4 세그먼트를 사용하므로, 다운로드는 "페치하고 합치고"가 핵심 로직. FFmpeg WASM이 필요 없어 다운로드 사이즈가 25MB 적고, 속도가 빠르다.
그럼 FFmpeg WASM은 언제 필요한가
다음은 FFmpeg WASM 없이 처리 곤란한 것:
- HLS-MPEG-TS 스트림. TS 패킷(188바이트 고정)을 디먹싱해서 H.264 NAL unit을 빼고 PTS/DTS를 추출해서 fMP4 박스를 빌드해야 한다. 직접 파서를 만들 수도 있고,
mux.js(npm)를 쓸 수도 있고, FFmpeg WASM에 맡길 수도 있다. 마지막이 가장 쉽고 신뢰할 수 있다. - 오디오/비디오 인터리빙. 비디오 세그먼트와 오디오 세그먼트가 따로 오는 경우(분할 트랙 DASH에서 흔함), 결합은 두 트랙을 시간순으로 인터리브하는 MP4를 만들어야 한다. fMP4로 가능하지만 까다롭고
tfhd의default-sample-flags같은 박스 이해가 필요. FFmpeg은-i video.mp4 -i audio.mp4 -c copy out.mp4로 끝. - codec 변환. H.265를 H.264로 바꾸는 등. 이것은 트랜스코딩이지 리먹스가 아니다. FFmpeg WASM으로 가능하지만 느리고 브라우저에서 이걸 하는 건 미친 짓이다——사용자의 CPU를 태우는 것이고, 브라우저 탭은 보통 우선순위가 낮아 스로틀된다.
- 컨테이너 변환. MKV를 MP4로, FLV를 MP4로. 날것의 codec 데이터를 다른 컨테이너 포맷으로 다시 패키징. FFmpeg은 이걸 잘한다.
- 필터 체인. 회전 메타데이터 정정, 오디오 지연 보정, 자막 트랙 삽입. 이런 작업은 FFmpeg 필터 그래프가 만들어져 있다.
간단한 결론: fMP4 세그먼트를 다루고 있고, codec을 안 바꾸고, 트랙 인터리빙이 간단하거나 없으면 → FFmpeg 불필요. 직접 결합. 그 외 → FFmpeg WASM.
styp 박스는 뭘까
각 미디어 세그먼트는 moof 앞에 styp 박스가 있다:
00 00 00 18 73 74 79 70 6D 73 64 68 00 00 00 00 6D 73 64 68 6D 70 34 64 69 73 6F 6D
s t y p m s d h m s d h m p 4 d i s o m
styp는 "segment type box"——이 파일이 fMP4 세그먼트임을 선언. msdh (movie fragment, random access 가능)는 "이 세그먼트는 단독으로 디코딩 가능"을 의미. mp4diso는 MP4 ISO 호환.
이건 결합 시 무시 가능——init에 이미 ftyp가 있으므로. 하지만 일부 플레이어는 styp를 좋아하므로 남겨두는 게 안전.
trun 박스: 샘플 테이블
trun(track run) 박스가 각 세그먼트 내 프레임을 기술:
aligned(8) class TrackRunBox
extends FullBox('trun', version, flags) {
unsigned int(32) sample_count;
// flags에 따라:
for (i=0; i<sample_count; i++) {
unsigned int(32) data_offset; // flag 0x000001
unsigned int(32) first_sample_flags; // flag 0x000004
// 각 샘플:
unsigned int(32) sample_duration; // flag 0x000100
unsigned int(32) sample_size; // flag 0x000200
unsigned int(32) sample_flags; // flag 0x000400
unsigned int(32) sample_composition_time_offset; // flag 0x000800
}
}
이게 MP4가 각 프레임을 "어느 샘플이 몇 바이트이고, 얼마나 오래 재생하고, DTS는 어디고, PTS 오프셋은 어디인지"를 아는 방법. 효율적이지만 읽기 어려운 포맷.
결합 시 왜 이게 중요한가: 다중 세그먼트를 결합하면, 각 trun의 오프셋은 여전히 자기 moof에 대해 상대적. data_offset 필드는 moof 끝에서 mdat 데이터 시작까지의 거리——이것은 세그먼트 내에서 변하지 않으므로 결합 후에도 그대로.
하지만 tfdt 박스——"track fragment decode time"——은 세그먼트의 절대 시작 시간. 세그먼트를 순서대로 놓으면 tfdt도 단조 증가. 결합된 파일은 유효.
멀티 트랙 fMP4의 결합
비디오와 오디오가 별개 세그먼트로 오는 경우:
init-video.mp4 video-1.m4s video-2.m4s ...
init-audio.mp4 audio-1.m4s audio-2.m4s ...
다운로드는 각 트랙의 세그먼트를 따로 페치한 후 결합:
init-video.mp4+video-N.m4s를 순서대로 →video.mp4init-audio.mp4+audio-N.m4s를 순서대로 →audio.mp4- 두 파일을 멀티플렉스:
ffmpeg -i video.mp4 -i audio.mp4 -c copy out.mp4
1, 2단계는 순수 JS로 가능(위의 결합 코드). 3단계는 FFmpeg이 필요, 각 트랙의 trun 테이블을 재작성해서 단일 moov 아래 양쪽 trak으로 합쳐야 한다. 직접 하려면 박스 레벨 조작이 필요하고, FFmpeg이 더 쉽다.
결합이 실패하는 경우
실패 케이스 1: codec 파라미터 변화. 일부 라이브 스트림은 인코딩 중 SPS/PPS를 바꾼다(해상도 변경, IDR 프레임에서 codec 프로파일 변경). init 세그먼트가 첫 번째 codec 파라미터를 기술하면, 이후 세그먼트는 다를 수 있다. 결합된 파일은 첫 부분은 재생되다가 변경 이후에 글리치.
실패 케이스 2: 세그먼트 번호 불연속. 라이브 스트림에서 세그먼트 번호가 건너뛰면 (서버가 죽었다가 살아나면), tfdt 시간은 연속이지만 mfhd 시퀀스는 건너뛴다. 대부분의 플레이어는 신경 안 쓰지만 일부는 고집.
실패 케이스 3: 타임스탬프 겹침. 일부 인코더는 tfdt가 정확히 연속이 아닌 미디어를 만든다 (1-2 프레임 겹침). 결합하면 두 프레임이 같은 시간을 가리켜 디코더가 혼란. 수정은 세그먼트 사이의 타임스탬프를 조정——FFmpeg은 mkvmerge로 가능하지만 fMP4 결합은 직접 조정이 필요.
코딩 시 주의할 것
바이트 정렬. ISOBMFF 박스는 8바이트 정렬(대부분). 박스를 읽거나 쓸 때는 정렬을 유지. 특정 박스는 4바이트 정렬도 있으므로 사양을 확인.
엔디안. 모든 ISOBMFF 정수는 빅 엔디언. JS의 DataView는 기본이 빅 엔디언이라 편리, 그냥 getUint32(offset)를 쓰면 된다.
박스 크기 특수값. 크기가 0이면 "파일 끝까지". 크기가 1이면 다음 8바이트가 64비트 크기. 항상 확인.
벤치마크: 순 JS 결합 vs FFmpeg WASM vs 네이티브
동일 워크로드——300개 fMP4 세그먼트 결합, 약 500MB:
| 수단 | 실측 시간 | 피크 메모리 | 다운로드 사이즈 |
|---|---|---|---|
| 순 JS 결합 | 0.4s | 510MB | 0 |
FFmpeg WASM (transcode 아님, -c copy 리먹스) | 4.2s | 660MB | 25MB |
네이티브 FFmpeg (-c copy) | 0.6s | 80MB | 0(설치됨) |
순 JS 결합이 가장 빠르다——FFmpeg은 WASM 초기화, FFmpeg 자체 메모리 관리, 멀티플렉스 오버헤드가 붙는다. 하지만 어디서나 작동, 인스톨 불필요, 510MB 피크 메모리는 큰편이지만 감당 가능.
전체 벤치마크는 브라우저 비디오 처리 성능 벤치마크에서.
참고 자료
- ISO/IEC 14496-12 — ISOBMFF 사양——공식 사양, 유료
- MP4 Sanitizer by BBC——오픈소스 Rust ISOBMFF 파서. 깨끗한 참고 구현
- Bento4——C++ MP4 툴킷. CLI 도구
mp4info,mp4dump가 디버깅에 편리 - FFmpeg 문서——포맷 레퍼런스,
movflags옵션의 의미 - mux.js——video.js의 JS MPEG-TS/FLV 파서, fMP4 생성기. FFmpeg 없이 처리하고 싶을 때
정리
MP4 리먹스는 "박스를 열어 데이터를 꺼내 다른 박스에 넣는 것". 트랜스코딩은 박스 안의 데이터를 디코딩/재인코딩. 둘의 작업량 차이는 100배——브라우저에서는 트랜스코딩은 피하고 리먹스를 택하라.
fMP4 스트림의 경우, 리먹스는 거의 "세그먼트를 순서대로 붙이는 것"이고, 박스 구조가 모든 메타데이터를 세그먼트마다 가지므로 결합은 그냥 Blob 결합. 직접 구현은 100줄 미만, 어느 브라우저에서든 작동.
FFmpeg WASM은 정말 필요할 때만——TS 디먹싱, 멀티 트랙 멀티플렉싱, 필터 체인, codec 변환——이런 시나리오의 가장 좋은 도구. 하지만 fMP4 세그먼트를 다운로드하고 있는 거라면, FFmpeg은 오버킬.
다음은 WebCodecs로 더 빠른 디코딩 경로 또는 여러 코덱/해상도의 벤치마크에서.
추천 글
- HLS 딥: 암호화, 멀티트랙, 기억해야 할 EXT-X 태그——HLS의 TS 세그먼트가 왜 fMP4보다 다루기 어려운가
- DASH 딥: SegmentTemplate, ContentProtection, 시점 전환 MPD——fMP4 세그먼트의 공급원
- FlowPick은 어떻게 브라우저에서 수백 개의 비디오 세그먼트를 하나의 MP4로 합치는가——이 글의 패턴이 FlowPick에서 어떻게 쓰이는가
- 브라우저 비디오 처리 성능 벤치마크——FFmpeg WASM, WebCodecs, 네이티브의 풀 벤치마크
- WebCodecs + Web Workers + OPFS: 브라우저 비디오 처리 실전——FFmpeg을 아예 안 쓰는 더 빠른 경로
- 스트리밍 다운로드는 합법인가?기술과 법적 체크리스트——DRM이 아닌 콘텐츠를 다운로드하는 법적 고려