본문 바로가기

JavaScript/NextJS

NextJS - FFmpeg.wasm으로 영상 용량 줄이기

배경

웹 서비스에서 영상 업로드 기능이 있을 때, 서비스 컨셉에 따라 용량이 큰 영상을 업로드하는 경우가 있는데 이것을 그대로 서버에 저장하는 것은 비용적으로 부담이 될 수 있습니다. 그래서 영상 용량을 줄여서 저장하는 것이 좋은데, 백엔드에서 영상을 직접 처리한다면 서버 자원(메모리, CPU)에 무리가 가서 서버가 다운되거나 문제가 발생할 수 있습니다. 따라서 클라이언트 측에서 영상 용량을 줄여서 업로드 한다면 서버의 부담을 덜 수 있을 것입니다.

 

물론 이런 방법을 사용하면, 영상 변환이 오래 걸릴 경우, 사용자 경험이 저하된다는 단점이 있으므로 주의해야 합니다.

 

이 글의 목표는 <input type="file>의 File Object를 FFmpeg.wasm을 사용해 클라이언트 측에서 직접 영상 용량을 줄이는 것이 목표입니다.


클라이언트 측(브라우저)에서도 영상을 변환할 수 있나요?

FFmpeg.wasm 모듈을 사용하면 가능합니다. 보통 FFmpeg은 콘솔이나 서버에서 사용되지만, FFmpeg.wasm은 브라우저가 직접 영상을 변환할 수 있도록 도와줍니다. 이외의 방법으로 Blob과 <canvas>를 이용한 방법이 있지만, 이를 단순히 구현하면 JavaScript가 싱글 스레드 언어이기 때문에 성능이 떨어집니다. 그래서 node의 내장 라이브러리인 worker_threads를 사용해서 직접 멀티 쓰레드를 구현해야 하므로 메모리를 수동으로 관리할 수 있는 low-level의 기술이 필요합니다. 반면 FFmpeg.wasm은 이러한 기능이 이미 포함되어 있으므로 비교적 쉽고 빠르게 구현할 수 있습니다.

 

FFmpeg.wasm에 대한 자세한 내용은 아래 링크에서 확인할 수 있습니다.

 

https://github.com/ffmpegwasm/ffmpeg.wasm


개발 환경

  1. M1 Macbook Pro
  2. GCP / GKE
  3. 비공개 버킷 / Cloud CDN
  4. 프런트엔드: NextJS

1. next.config.js 설정

FFmpeg.wasm는 멀티 쓰레드로 영상을 변환하기 때문에 브라우저가 SharedArrayBuffer를 사용할 수 있어야 합니다.

http://hacks.mozilla.or.kr/2017/11/a-cartoon-intro-to-arraybuffers-and-sharedarraybuffers/

 

하지만 SharedArrayBuffer는 브라우저가 다른 도메인에서 가져온 자원에 대해 접근을 제한하는 보안 정책인 Same-origin policy에 의해 제한을 받습니다. Same-origin policy는 보안상 중요한 정보인 쿠키, 스크립트 등이 외부 도메인으로 전송되는 것을 방지하기 위해 브라우저에서 적용하는 보안 정책입니다.

 

따라서 SharedArrayBuffer를 사용하려면, 서로 다른 웹페이지나 스크립트가 동일한 메모리를 공유하도록 허용해야 합니다. 이를 위해서 Cross-Origin-Embedder-PolicyCross-Origin-Opener-Policy를 사용하여 보안성을 유지하면서도 SharedArrayBuffer를 사용할 수 있도록 할 수 있습니다.

 

NextJS에서 이 헤더를 설정하려면 next.config.js에 아래와 같이 추가해주면 됩니다.

 

const nextConfig = {
  ...(생략),
  async headers() {
    return [
      // ffmpeg SharedArrayBuffer 사용을 위한 설정
      {
        source: '/(.*)',
        headers: [
          {
            key: 'Cross-Origin-Embedder-Policy',
            value: 'require-corp',
          },
          {
            key: 'Cross-Origin-Opener-Policy',
            value: 'same-origin',
          },
        ],
      },
    ];
  },
};

module.exports = nextConfig;
  • source: '/(.*)': 모든 라우팅 경로에 대한 페이지에 대해 아래의 헤더를 응답합니다.
  • Cross-Origin-Embedder-Policy: 리소스가 어떤 허용 정책에 따라 로드되어야 하는지를 브라우저에게 전달합니다. require-corp 값은 리소스가 서버 응답 헤더 Cross-Origin-Resource-Policy에 따라 로드되어야 함을 의미합니다.
  • Cross-Origin-Opener-Policy: 리소스가 브라우저에서 어떻게 사용되는지 정의합니다. same-origin 값은 리소스가 자신의 출처와 같은 프레임 내에서 사용되어야 함을 의미합니다.

 

그런데.. 위와 같이 헤더를 설정하면, 개발하고 있는 웹 사이트에서 이미지나 영상 파일에 CDN과 같은 외부 리소스를 사용하려고 할 때 오류가 발생합니다.

이는 COEP(Cross-Origin Embedder Policy)가 브라우저에서 Cross-Origin Resource를 로드하지 못하도록 제한하기 때문입니다. 따라서, Resource를 보내주는 쪽에서 각 Resource에 적절한 CORP(Cross-Origin Resource Policy)를 지정해야 합니다.

 

저의 경우  CDN을 사용하고 있으므로, GCP Cloud CDN의 백엔드 응답에 {Cross-Origin-Resource-Policy: cross-origin} 헤더를 추가해서 브라우저가 Resource를 로드할 수 있도록 했습니다.

 

 

Cross-Origin-Resource-Policy: 리소스가 웹 사이트가 아닌 다른 위치에서 제공되는 경우 cross-origin이며, 저는 CDN을 사용하고 있기 때문에 이 값을 설정했습니다. 이 헤더에 대해 자세히 알고 싶으시다면 아래 링크를 확인해 볼 수 있습니다.

https://developer.mozilla.org/en-US/docs/Web/HTTP/Cross-Origin_Resource_Policy

https://resourcepolicy.fyi/


2. FFmpeg 패키지 설치

모듈을 사용하기 위해선 아래의 명령어로 패키지를 설치해야 합니다.

npm install @ffmpeg/ffmpeg @ffmpeg/core

3. FFmpeg.wasm의 코어 파일을 public 폴더로 이동

구현 단계에서 ffmepg.load()를 하면 FFmpeg.wasm은 기본적으로 'http(s)://example.com/node_modules/@ffmpeg/core/dist' URL에서 코어 파일을 찾습니다. 하지만 NextJS에선 이 경로를 요청받으면 파일을 제공해주지 않기 때문에, public 경로에서 이를 제공해주어야 합니다.

 

FFmpeg.wasm의 코어 파일은 node_module에 있으므로 이걸 찾아서 public 폴더로 복사해주시면 됩니다.

저는 /public/ffmpeg 폴더로 복사했습니다.

 

 

드디어 FFmpeg.wasm을 사용할 준비가 되었습니다.

이제 구현해보죠.


4. 구현

video.ts

import { createFFmpeg, fetchFile } from '@ffmpeg/ffmpeg';

// 비디오 파일의 용량을 줄이는 함수
export async function convertVideo(file: File, progress: (percentage: number) => void) {
  const input = file.name; // input 파일명
  const output = file.name.replace(/\.[^/.]+$/, '') + '.mp4'; // output 파일명

  // log: 브라우저 콘솔에 ffmpeg 로그를 찍을 것인지 여부
  // corePath: ffmepg 코어 파일 경로를 직접 지정
  const ffmpeg = createFFmpeg({ log: false, corePath: `${process.env.NEXT_PUBLIC_URL}/ffmpeg/ffmpeg-core.js` });

  // 비디오 파일을 메모리에 로드
  await ffmpeg.load();
  ffmpeg.FS('writeFile', input, await fetchFile(file));

  // 진핸 상황 콜백
  ffmpeg.setProgress(({ ratio }) => progress(Math.round(ratio * 100)));

  /* 비디오 파일 변환
   * -vcodec: 코덱, libx264(H.264)
   * -preset: 인코딩 프리셋, slow(높은 압축률, 이외에 ultrafast, superfast, veryfast, faster, fast 옵션 있음)
   * -crf: 숫자가 낮을수록 고품질, 보통 18~28 사용, 0~51까지 사용 가능
   */
  await ffmpeg.run('-i', input, '-vcodec', 'libx264', '-preset', 'ultrafast', '-crf', '28', output);

  // 결과 파일 다운로드
  const data = ffmpeg.FS('readFile', output);
  const blob = new Blob([data.buffer], { type: 'video/mp4' });

  return blob;
}

 

위 함수에 파라미터로 File Object와 progress 콜백 함수를 넘겨주면, File을 인코딩하고, 진행률을 전달받은 progress 콜백 함수로 전달해줍니다.

 

ultrafast 프리셋을 기준으로 .MOV 400MB 영상 파일을 변환한 결과 120MB로 변환됐으며, 70%의 압축률을 보여줬습니다.


이슈

iPhoneXS Safari에서 테스트 한 결과, 1개의 파일은 변환이 잘 되었지만, 2개째부터 ffmpeg.load() 부분에서 'out of memory' 에러가 발생했습니다. 아직 해결하지 못했으며, 해결되면 글을 수정할 예정입니다.

 

아래는 이 문제와 관련해서 참고할 수 있는 링크입니다.

https://github.com/ffmpegwasm/ffmpeg.wasm/issues/299

https://ddochea.tistory.com/149

 


마치며

FFmpeg.wasm을 사용하는 방법은 간단하지만, 사용하기 위한 환경을 세팅하는 과정에서 많이 헤멨고 여러 헤더들에 대해 공부가 되었던 경험이었습니다. 아직 0.11.6이 최신 버전인 것으로 보아, 여러 환경에서의 안정성이 다소 떨어지는 모습이었네요.