NearVid

2026-07-20

NearVid의 브라우저 네이티브 MP4: WebCodecs, H.264를 찾지 못한 스파이크, 그리고 Mediabunny를 선택한 이유

지난 글은 이런 약속으로 끝났다. NearVid의 GPL-프리 ffmpeg.wasm 코어는 H.264로 MP4/MOV를 재인코딩할 수 없다 — 로열티 프리 인코더 자체가 존재하지 않기 때문이다 — 하지만 브라우저 자체의 WebCodecs API를 사용하는 별도의 독립적인 파이프라인이 실제 MP4 출력을 담당하며, 이건 "이 글이 아니라 다른 글에서 다룰 주제"라고 말이다. 이 글이 바로 그 글이다. 이야기는 두 단계로 이루어진다: 필요한 코덱이 자체 테스트 환경에서 완전히 사용 불가능하다는 것을 발견한 탐색적 스파이크, 그리고 그럼에도 스파이크가 사용한 것과는 다른 라이브러리로 — 스파이크 스스로가 드러낸 이유들 때문에 — 결국 출시된 프로덕션 통합.

스파이크가 확인하려 한 것

아이디어 자체는 간단하다: VideoEncoder/VideoDecoder(영상)와 AudioEncoder/AudioDecoder(오디오)는 브라우저 내장 WebCodecs API로, 하드웨어든 벤더가 라이선스한 소프트웨어 인코더든 브라우저 자신이 갖고 있는 H.264/AAC 구현에 처리를 넘긴다. NearVid는 그 코덱 코드를 전혀 번들하지 않으므로, ffmpeg.wasm 엔진에서 MP4 경로를 막았던 GPL·특허 문제가 여기엔 적용되지 않는다. 스파이크의 임무는 이 메커니즘이 스펙만 보고 가정하는 게 아니라 실제 브라우저, 실제 파일로 정말 동작하는지 확인하는 것이었다.

실제로 발견한 것: 이 샌드박스에서 H.264는 완전히 없었다

스파이크는 실제 Playwright Chromium(샌드박스에 설치된 그 정확한 엔진 버전, 어떤 엔진에 대한 설명이 아니라)에서 실행되었고, VideoEncoder.isConfigSupported()를 H.264/HEVC 코덱 문자열 조합 9가지에 대해 호출했다: H.264 Baseline(avc1.42001f, 기본값·prefer-software· prefer-hardware 가속 힌트 각각으로 테스트), H.264 Main(avc1.4d0028), 두 레벨의 H.264 High(avc1.640028, avc1.64001f), 1080p의 H.264 High (avc1.640033), HEVC Main(hvc1.1.6.L93.B0). 아홉 개 전부 supported: false가 돌아왔다. 같은 방식으로 테스트한 VP9 대조군 문자열 (vp09.00.10.08)은 true가 나왔다 — 즉 테스트 장치가 고장 난 게 아니라, 실재하는 특정한 부재였다. 오디오도 마찬가지였다: AAC-LC(mp4a.40.2)와 MP3 모두 supported: false를, Opus만 true를 보고했다.

isConfigSupported()는 어디까지나 질의(query)이고, 질의는 틀릴 수 있다(아래에서 다시 다룬다). 그래서 스파이크는 거기서 멈추지 않았다. 실제 H.264 소스 파일(fixtures/src.mp4)을 mp4box.js로 demux해서 진짜 H.264 샘플을 꺼낸 뒤, 그걸 실제 VideoDecoder에 직접 먹였다. 결과는 얌전한 "미지원" 플래그가 아니라 즉각적인 런타임 에러였다 — "Unsupported configuration. Check isConfigSupported() prior to calling configure()."가 디코더가 아무것도 하기 전에 에러 콜백에서 곧바로 발화했다. 가용성 플래그와 실제 동작이 정확히 일치했다: 이 특정 브라우저 빌드엔 H.264가 없다, 그게 전부였다.

왜 그런가(조사 결과이지, 확정된 사실이 아니다)

스파이크 자신의 §1-5는 "발견"과 "가설"의 차이를 명확히 구분하고 있고, 이 섹션은 가설이다. Chrome의 소프트웨어 H.264 인코더는 Cisco의 OpenH264인데, 정상적인 Google 브랜드 Chrome 설치본에서는 이 구성요소가 정적으로 컴파일되어 있지 않다 — Chrome의 Component Updater가 런타임에 별도로 내려받는다. Playwright가 번들하는 "Chromium for Testing"은 그 갱신 채널을 타지 않는 오픈소스 빌드라서, 작업 가설은 OpenH264 구성요소(그리고 어쩌면 다른 라이선스된 코덱 구성요소들도)가 애초에 이 빌드엔 설치되지 않았을 것이라는 것이다. 이건 그럴듯하고 근거가 있는 설명이지만, 스파이크가 직접 재현해서 증명한 것은 아니다 — 이 샌드박스엔 대조해볼 정식 Google 브랜드 Chrome 바이너리가 없었기 때문에, "실제 Chrome엔 정말 있는가"는 열린 질문으로 남았다 — 추정하고 넘어가지 않고 의도적으로 미확인으로 남겨둔 것이다.

대신 증명한 것: 파이프라인 그 자체는 진짜였다

한 빌드에서 H.264가 없다는 것이 메커니즘 자체가 검증되지 않았다는 뜻은 아니다 — 이 브라우저가 실제로 지원하는 코덱으로 바꿔서, 똑같은 파이프라인 구조를 실제로 돌려봐야 한다는 뜻이다. 스파이크는 전체 체인을 만들었다: mp4box.js로 소스를 demux하고, 추출한 샘플을 VideoDecoder/AudioDecoder로 디코드하고, VP9 영상 + Opus 오디오(실제로 supported: true가 나온 코덱들)로 VideoEncoder/AudioEncoder를 통해 재인코드하고, mp4-muxer로 결과물을 MP4에 먹싱한 뒤, 나온 바이트를 실제 <video> 엘리먼트에 로드해서 정말로 재생되는지 확인했다. 실제로 재생됐다: 영상 샘플 90개가 디코드·재인코드되었고(첫 청크 타입은 "key", 모든 청크가 0바이트 아님), 오디오 샘플 151개가 디코드·재인코드되었으며, 먹싱된 파일은 유효한 ftyp 박스로 시작했다 — 그리고 가장 중요한 부분: 실제 재생이 확인됐다. 재생 시간 3.04초는 원본과 일치했고, 해상도 640×360도 원본과 일치했으며, playbackAdvanced: true는 play() 도중 currentTime이 실제로 전진했다는 뜻이다 — 단지 파일 헤더가 파싱됐는지만 확인한 게 아니라는 것이다. 이게 이 스파이크 전체를 떠받치는 핵심 증거다: demux→decode→encode→mux→재생 메커니즘은 실제 데이터로 처음부터 끝까지 진짜로 작동한다. 코덱 문자열을 vp09.xx에서 avc1.xx로 바꾸기만 하면 H.264가 실제로 있는 브라우저에서도 같은 코드 경로가 그대로 동작할 것으로 강하게 기대되지만, 그 구체적인 교체는 이 샌드박스에서 실제로 실행해본 적은 없다 — 스파이크는 그걸 단정하지 않고 있는 그대로 밝히고 있다.

실제로 코드를 돌리다 만난 진짜 버그 두 개

둘 다 파이프라인을 종이 위에서 설계만 한 게 아니라 실제로 만들어 실행했기 때문에 드러난 것들이다:

  • mp4box.js의 추출 옵션을 너무 늦게 설정한 문제. 파일 로딩 프라미스가 resolve된 뒤에 setExtractionOptions()/start()를 호출했더니 샘플 수가 0으로 나왔다. 이유는 appendBuffer()가 파일이 들어오는 대로 동기적으로 전체를 파싱하기 때문이다 — 그래서 추출 옵션을 나중에 설정할 때쯤이면 파싱은 이미 끝나 있고, 추출해 넣을 것이 아무것도 남아있지 않다. 해결책은 파싱이 끝나기 전, onReady 콜백 안에서 setExtractionOptions()/start()를 호출하는 것이었다.
  • 타임아웃 전까지 절대 실행되지 않은 video.src 할당. 재생 검증 코드는 원래 비디오의 loadedmetadata 이벤트를 기다리는 await new Promise(...) 뒤에 video.src = blobUrl을 할당했다 — 소스 자체가 할당되지 않으면 애초에 그 대기가 resolve될 리가 없다. 소리 내어 말해보면 정확히 그 모습 그대로인 버그다: await는 그 뒤에 적힌 모든 코드를 프라미스가 해결될 때까지 미룬다. 따라서 그 프라미스를 시작시켜야 할 코드는 이벤트 리스너를 등록한 직후, 프라미스 executor 안에서 실행돼야 한다 — await한 줄 뒤가 아니라.

둘 다 특별히 이색적인 버그는 아니다. 실제 파일로 코드를 실제로 돌려봐야만 드러나는, 지극히 평범한 종류의 실수들이다 — 그리고 그게 바로 스파이크가 "API 모양이 맞아 보인다"에서 멈추지 않고 실제로 돌려본 이유다.

스파이크에서 프로덕션으로: mp4box.js + mp4-muxer가 아니라 Mediabunny를 쓴 이유

스파이크가 mp4box.js(demux)와 mp4-muxer(mux)를 쓴 건 이 작업에 어울리는, permissive 라이선스를 가진 가장 당연한 라이브러리들이었기 때문이다 — 그런데 npm install을 실행하던 중 실제로, 요청하지 않았는데도 이런 경고가 떴다: npm warn deprecated mp4-muxer@5.2.2: This library is superseded by Mediabunny. Please migrate to it. 저장소 README도 똑같이 말한다: mp4-muxer는 deprecated 상태이고 더 이상 유지보수되지 않으며 신규 기능도 버그 수정도 없다. 스파이크는 이걸 기록하고 Mediabunny를 올바른 프로덕션 선택지로 지목했지만, 직접 설치하거나 테스트하지는 않았다 — 그 검증은 의도적으로 나중으로 미뤄뒀다, 돌려보지도 않고 주장만 하는 대신.

프로덕션(packages/near-video-engine/mp4-engine.js)은 그 검증을 실제로 수행했고, 단순히 예전 라이브러리가 deprecated라서가 아니라 그 파일의 헤더 주석에 기록된 세 가지 구체적인 이유로 Mediabunny로 전환했다:

  • 더 넓은 컨테이너 지원. mp4box.js는 ISOBMFF(MP4/MOV)만 이해한다. Mediabunny의 Input은 MP4, MOV, WebM, MKV, MPEG-TS를 네이티브로 읽어내므로, 추가 작업 없이 이 파이프라인이 demux할 수 있는 소스 파일의 범위가 넓어진다.
  • 단순한 가용성 플래그가 아니라 Firefox에서의 진짜 스모크 테스트. Mediabunny의 canEncodeVideo()/canEncodeAudio()는 표준 isConfigSupported() 체크를 호출하지만, Firefox에서만큼은 실제로 한 프레임을 인코드하는 스모크 테스트도 함께 실행한다 — Mediabunny 자신의 이슈 트래커가 그곳에서 isConfigSupported()가 거짓말을 할 수 있다는 걸 발견했기 때문이다. 이건 가정이 아니다: 스파이크 자신의 §5-2가 실제로 접수된 버그, Mozilla Bugzilla #1918769("WebCodecs VideoDecoder fails on h.264 video frame")를 인용하고 있다 — 가용성 플래그는 된다고 했지만 실제 디코드는 실패한 사례로, 스파이크 자신의 §1-4가 이 샌드박스의 Chromium에서 H.264에 대해 파헤쳤던 것과 거의 똑같은 "플래그 대 실제"의 불일치다.
  • 번들 해제된 별도 빌드 단계가 필요 없다. Mediabunny가 생성하는 모든 워커는 그 자체로 완결된 Blob-URL 워커다(워커 함수가 런타임에 Blob으로 직렬화되는 방식이지, new Worker(new URL(...))로 별도 파일에서 불러오는 방식이 아니다). 첫 글의 엔진이 쓰는 @ffmpeg/ffmpeg와 달리(그리고 그 자체의 빌드 과정 우회책과 달리), Mediabunny는 build.mjs에 별도의 번들 해제 복사 단계가 필요 없다 — 그냥 평범한 esbuild 의존성이다.

Mediabunny는 MPL-2.0 라이선스다 — GPL이 아니고, 스파이크가 확인했던 BSD/MIT 라이브러리들과 마찬가지로 클로즈드소스 상용 사용에 문제가 없다.

기본값이 아니라 의도적으로 내린 선택들

mp4-engine.js의 두 가지 결정은 무엇을 막으려는 것인지 모르면 그냥 지나치기 쉽다:

  • Mediabunny 자체의 기본값이 아니라 H.264 Baseline 프로파일. 코덱 문자열을 지정하지 않으면 Mediabunny는 High 프로파일(avc1.64...)을 기본값으로 쓴다. 프로덕션은 대신 avc1.42001f(Baseline, 레벨 3.1)을 고정으로 쓴다 — 스파이크 자신이 §1-2에서 테스트했던 바로 그 문자열이다 — 왜냐하면 §1-5의 조사가 Chrome의 소프트웨어 H.264 인코더를 구체적으로 OpenH264로 추적했고, OpenH264는 Constrained Baseline 프로파일만 구현하기 때문이다. 더 높은 프로파일로 프로빙(또는 인코딩)하면 미지원으로 보고되어, 사용자가 이 기능을 가장 필요로 하는 바로 그 소프트웨어 인코드 전용 기기에서 MP4 기능을 조용히 감춰버릴 수 있다. 가용성 프로브와 실제 인코드 모두 같은 코덱 문자열을 쓴다 — 한 설정으로 프로빙하고 더 까다로운 설정으로 인코딩하면, Mediabunny가 Firefox를 위해 이미 우회하고 있는 바로 그 "플래그 대 실제" 간극을 다시 끌어들이는 셈이 된다.
  • 영상뿐 아니라 오디오 인코드 지원도 함께 요구한다. checkMp4EncodeSupport()는 canEncodeVideo()와 canEncodeAudio()가 둘 다 성공해야만 supported: true를 보고한다. AAC를 쓸 수 없다고 오디오 트랙을 조용히 빼버리는 "MP4 출력"은 사용자에게 실제로 약속한 "H.264 + AAC" 기능을 정직하게 구현한 게 아니기 때문이다.

그리고 의도적으로 없는 것도 하나 있다: 이 코드 어디에도 User-Agent 문자열로 분기하는 부분이 없다. 모든 게이트는 스파이크와 프로덕션 모듈 둘 다 그래야 한다고 못박은 대로 실제 isConfigSupported()/canEncodeVideo()/canEncodeAudio() 호출이다 — UA 스니핑이야말로 이 조사 전체가 애초에 피하려던 바로 그런 종류의 가정이기 때문이다.

이 기능이 다루지 않는 것

이건 기존 엔진 위에 얹힌 추가적이고 조건부인 파이프라인이지, 기존 엔진을 대체하는 게 아니다. 첫 글에서 소개한 GPL-프리 ffmpeg.wasm 엔진은 여전히 WebM과 GIF 출력으로 가는 유일한 경로이고, Mediabunny가 읽지 못하는 컨테이너를 처리하는 것도 여전히 이 엔진이다. Mediabunny가 실제로 검증되어 지원하는 포맷은 MP4/MOV/WebM/MKV/MPEG-TS다 — AVI, WMV, 3GP는 지원 목록에 없다. canReadForMp4()는 이런 컨테이너의 파일에 대해서는 그냥 MP4 옵션을 제공하지 않는다. ffmpeg.wasm을 거쳐 중간 포맷으로 먼저 바꾼 뒤 WebCodecs로 두 번째 재인코딩을 하는 식으로 조용히 우회하지 않는다는 뜻이다. 그 2단계 경로는 검토됐지만 기각됐다: 무거운 파이프라인 두 개를 연결하고 결과물 하나를 위해 손실 있는 재인코딩을 두 번 거치는 것은, 그 파일 하나에 대해 MP4를 제공하지 않는 것보다 더 나쁜 트레이드오프다 — 어느 쪽이든 방문자는 트림·WebM·GIF는 그대로 쓸 수 있다. 그리고 MP4 출력이 제공되는 경우에도 그 가용성은 절대 보편적이거나 보장된 게 아니다 — 브라우저 이름으로 짐작하는 게 아니라, 매번 실시간으로 특정 브라우저· 기기를 확인해서 결정한다.

정직한 요약

스파이크 자체의 판정은 GO가 아니라 CONDITIONAL-GO였고, 그 구분은 중요했다: demux→decode→encode→mux→ 재생 메커니즘을 실제 파일로 실제로 증명했고 그 과정에서 진짜 버그 두 개를 잡아냈지만, H.264 가용성 자체는 증명하지 못했다 — 테스트할 수 있었던 유일한 브라우저 빌드에 그게 없었기 때문이다. 조사로 얻은 설명(Chrome의 Component Updater를 통한 OpenH264)은 근거는 있지만 확정된 사실은 아니다. 프로덕션은 그럼에도 출시됐다 — 증명된 메커니즘과, 코덱이 정말로 없을 때는 안전하게 실패하는 런타임 기능 감지 위에서. 그리고 그 과정에서 스파이크의 mp4box.js + mp4-muxer 조합에서 Mediabunny로 업그레이드했다 — 더 넓은 포맷 지원, 자기 능력에 대해 거짓말한다고 알려진 브라우저에서의 진짜 스모크 테스트, 그리고 이 조사 전체가 애초에 잡아내려던 바로 그 실패 양상들을 막기 위한 의도적인 프로파일·이중 트랙 체크라는, 구체적이고 문서화된 이유들 때문이었다.

광고
← NearVid

이 페이지는 동의하신 경우에만 광고를 표시합니다.