이 글은 media-crop 저장소MediaCropEditor 컴포넌트를 기준으로 작성했습니다. img-compressor가 이미지를 다뤘다면, 이 프로젝트는 같은 "서버에 올리지 않고 브라우저에서 처리한다"는 원칙을 영상·음성 파일에 적용한 사례입니다.

서버 없이 영상을 자르려면 인코더가 브라우저 안에 있어야 한다

영상에서 특정 구간만 남기는 작업은 보통 서버의 ffmpeg로 처리합니다. media-crop은 이 인코더 자체를 WebAssembly로 컴파일된 @ffmpeg/ffmpeg 패키지로 브라우저에 불러옵니다. 코어 스크립트와 wasm 파일을 CDN에서 받아 Blob URL로 변환한 뒤 로드하는데, 이 초기화는 시간이 걸리기 때문에 파일을 선택하기 전부터 미리 백그라운드에서 실행해 둡니다.

const ffmpegInstance = new FFmpeg();
const baseURL = 'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/umd';
await ffmpegInstance.load({
  coreURL: await toBlobURL(\`\${baseURL}/ffmpeg-core.js\`, 'text/javascript'),
  wasmURL: await toBlobURL(\`\${baseURL}/ffmpeg-core.wasm\`, 'application/wasm'),
});

자르는 명령에 -c copy를 넣은 이유

실제로 구간을 잘라내는 호출은 다음과 같습니다. 여기서 핵심은 -c copy입니다.

await ffmpeg.exec([
  '-ss', range[0].toFixed(3),
  '-i', inputName,
  '-t', (range[1] - range[0]).toFixed(3),
  '-c', 'copy', '-map', '0', outputName
]);

-c copy는 영상과 음성을 다시 압축하지 않고, 원본 스트림에서 해당 구간의 데이터를 그대로 잘라 새 파일에 담으라는 옵션입니다. 재인코딩을 하지 않으므로 WebAssembly 위에서도 비교적 빠르게 끝나고, 화질이나 음질이 추가로 손상되지 않습니다. 무거운 인코딩을 메인 스레드 부담 없이 브라우저에서 끝내야 하는 도구일수록 이 차이가 체감됩니다.

빠른 대신 포기하는 것

다만 스트림을 그대로 잘라내는 방식에는 대가가 있습니다. 영상은 키프레임 단위로만 정확하게 잘릴 수 있어서, 사용자가 슬라이더로 지정한 시작 지점이 가장 가까운 키프레임으로 당겨질 수 있습니다. 프레임 단위로 정확한 절삭이 필요하다면 결국 구간 주변을 다시 인코딩해야 하는데, media-crop은 현재 그 정밀도보다 처리 속도와 품질 보존을 우선한 구조입니다. 이 절충은 코드에 적혀 있지 않으므로, 이런 도구를 직접 만들거나 평가할 때는 결과 파일의 시작 지점을 실제로 재생해 확인하는 과정이 필요합니다.

진행률은 ffmpeg 이벤트에서 그대로 가져온다

처리 중 진행률 표시는 별도로 추정하지 않고, ffmpeg 인스턴스가 보내는 progress 이벤트를 그대로 상태에 반영합니다.

ffmpegInstance.on('progress', ({ progress }) => {
  setProgress(Math.round(progress * 100));
});

이 값은 인코딩이 있는 작업일수록 의미가 분명하지만, -c copy처럼 재인코딩이 없는 작업에서는 실제 데이터 복사량 대비 추정치에 가깝습니다. 그래도 사용자에게 "멈췄다"는 인상을 주지 않기 위해 진행률 자체는 항상 표시합니다. 입력 파일과 결과 파일의 미리보기 URL을 각각 어떻게 정리하는지는 Object URL 메모리 누수 가이드에서 함께 다룹니다.

전체 코드는 MediaCropEditor.tsx에서 확인할 수 있습니다.