👋 개요
사내 광고소재 Video는 모종의 이유로 .webm 형식으로 되어있다.
문제는 iOS에서는 이 형식의 video를 지원하지 않는다는 것인데, MacOS, Android 등등에서 잘 돌아가던 녀석이 아이폰에서만 돌아가지 않았다.
여러 대안이 있지만(여러래봤자 당장 생각나는 건 두가지 뿐이지만) 다른 파트쪽에 손을 벌리지 않고 FE 자체적으로 이 이슈를 해결해보고싶었다. 그래서 Web Assembly를 이용해 ffmpeg를 브라우저에서 실행해 webm 파일을 mp4 파일로 인코딩 후 view에 띄우려고 한다.
🤔 과정
A to Z로 모든 부분을 구현하기엔 살짝 막막했으나 @ffmpeg/ffmpeg 라는 좋은 라이브러리가 이미 개발 되어있었고 이것을 사용했다.
튜토리얼과 문서를 참고해 아래의 코드를 구현하였다.
Component.tsx
function VideoViewer(props: VideoViewerProps){
const { src, ...restProps } = props;
const [isConverting, setIsConverting] = useState(false);
const [convertUrl, setConvertUrl] = useState('');
...
const handleConvertVideo = async (src: string) => {
try {
setIsConverting(true);
const file = await convertURLtoFile(src);
if (src?.includes('.webm') && isIos) {
const url = await convertVideo(file);
setConvertUrl(url);
return;
}
setConvertUrl(src);
} catch (e) {
throw new Error(`failed: ${e instanceof Error ? e.message : ''}`);
} finally {
setIsConverting(false);
}
};
useEffect(()=>{
if(src)
},[src])
return <video src={convertUrl} />
}
function.ts
const ffmpeg = createFFmpeg({ log: true });
const convertVideo = async (file: File | string) => {
try {
if (!ffmpeg.isLoaded()) {
await ffmpeg.load();
}
const name = getFileName(file);
ffmpeg.FS('writeFile', name, await fetchFile(file));
await ffmpeg.run(
'-i',
name,
'-c:v',
'libx264',
'-crf',
'28',
'-preset',
'ultrafast',
'output.mp4',
);
const data = ffmpeg.FS('readFile', 'output.mp4');
return URL.createObjectURL(new Blob([data.buffer], { type: 'video/mp4' }));
} catch (e) {
return '';
}
};
💡이슈1: cross-origin isolated
위의 코드를 실행시킨 결과 funtion.ts의 line 6의 ffmpeg.load()에서 wasm을 load중에 코드가 pending 되어 현상이 있었다.
공식 문서에 Browser에서 사용할 경우 친절하게 설명되어있었다.
SharedArrayBuffer is only available to pages that are cross-origin isolated. So you need to host your own server with Cross-Origin-Embedder-Policy: require-corp and Cross-Origin-Opener-Policy: same-originheaders to use ffmpeg.wasm.
문서를 잘 읽자…
나는 vite를 사용중이었기 때문에 config 파일에 해당 header를 달아주어 dev 서버에서 웹사이트가 cross-origin isolated적으로 동작할 수 있게 했다.
배포 시에는 실제 동작할 웹서버에 header를 설정해주어야한다.
vite.config.js
defineConfig({
...,
server:{
...,
headers:{
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
}
}
})
cross-origin isolated에 대해 더 자세히 알고 싶다면 https://web.dev/i18n/ko/coop-coep/ 아티클을 참고하면 좋다.
설정 완료 후 동일 코드로 테스트해보니 감쪽같이 완벽하게 동작하였다… 아이폰에서도 webm이 잘 동작한다.
그러나…
💡이슈2: img, video 태그 내의 src 접근 차단
iOS환경을 제외하고는 굳이 인코딩에 리소스를 사용할 필요가 없어 위에 코드에서 환경과 확장자에 따른 분기처리를 해주었다.(Component.tsx line 13)
그런데 위의 설정한 cross-origin isolated가 영향을 끼쳐 아래와 같은 에러가 발생했다.
Specify a Cross-Origin Resource Policy to prevent a resource from being blocked
Because your site has the Cross-Origin Embedder Policy (COEP) enabled, each resource must specify a suitable Cross-Origin Resource Policy (CORP). This behavior prevents a document from loading cross-origin resources which don’t explicitly grant permission to be loaded.
To solve this, add the following to the resource’ response header:
-> Cross-Origin-Resource-Policy: same-site if the resource and your site are served from the same site.
-> Cross-Origin-Resource-Policy: cross-origin if the resource is served from another location than your website. ⚠️If you set this header, any website can embed this resource.
Alternatively, the document can use the variant: Cross-Origin-Embedder-Policy: credentialless instead of require-corp. It allows loading the resource, despite the missing CORP header, at the cost of requesting it without credentials like Cookies.
정석적으로는 리소스(비디오)의 Response Header를 추가해줘야 한다.
하지만, 클라이언트 단에서의 작업만으로 이슈를 마무리 하고싶었고 Proxy 설정을 통해 CORP를 우회하여 해당 URL을 fetch하여 blob형태로 바꿔준 후 클라이언트 단에서 URL을 생성해 넣어주는 방식으로 해결하였다.
Component.tsx
function VideoViewer(props: VideoViewerProps){
...,
const handleConvertVideo = async (src: string) => {
try {
const file = await convertURLtoFile(src); // proxy를 통해 resource fetch
if (src?.includes('.webm') && isIos) {
...
}
setConvertUrl(URL.createObjectURL(file)); // 기존 src url이 아닌 url 생성하여 set
} ...
};
...
return <video src={convertUrl} />
}
🎯 결과
느리다.
아무래도 파일시스템을 이용할 거고 인코딩 작업이라 느린 건 당연하다. fps30 1920x540해상도의 30초의 영상을 인코딩하는데에
m1 iMac Chrome: 3500ms
iPhone 12 Safari: 8200ms
의 시간이 걸렸다. 결국 중요한 건 webm을 지원하지 않는 iOS인데 API call time을 제외하고 8초라는 건 아무래도 사용자 경험을 해칠 가능성이 커보인다. (그리고 더 이전에 나온 iPhone의 경우 더 느려질 수 있겠다...)
사용자의 액션에 의해 동작하고 progress를 제공함으로써 사용자가 기다리게끔 하는 시나리오면 적절한 대응이었을 것 같으나 나의 경우에는 자연스럽게 노출되어야하는 광고소재에 해당하는 내용이라 알맞지 않은 방식이었던 것 같다.
(사실 비디오 사이즈가 작아 느려도 2초 정도 안에서 해결할 수 있을 줄 알았다…)
결국 백엔드 쪽에서 주기적으로 배치를 돌려 낮은 해상도의 mp4 파일을 생성해주고 그 url을 response하는 것으로 처리 및 해결하였다.
아예 지원하지 않는 형식을 클라이언트 단에서 우회하여 처리하였다는 것과 wasm을 처음으로 실무에 적용시켜보았다는 것에 의의를 두어야겠다.🙂