Browser Image Compression 라이브러리에서 이미지를 압축하는 법

ggong·2022년 6월 8일
13
post-thumbnail

최근 에디터 기능을 개선하면서
사용자가 이미지를 업로드 했을때, 자동으로 이미지 사이즈를 압축해주는 라이브러리가 필요해졌다.
몇가지 옵션들을 찾다가 Browser-image-compression 이라는 괜찮은 라이브러리를 발견!

https://www.npmjs.com/package/browser-image-compression

자바스크립트 기반으로 된 이미지 압축 라이브러리인데,
문서도 간결하고 API를 보니까 사용법도 직관적이다.
현재까지 릴리즈된 버전은 v2.0.0

사용법은 간단하다.
압축을 원하는 파일과 옵션을 지정하고 imageCompression 비동기 함수를 사용해
압축된 이미지를 받아온 다음 원하는 후 처리를 하면 된다.

async function handleImageUpload(event) {

  const imageFile = event.target.files[0];
  const options = {
    maxSizeMB: 1, // 허용하는 최대 사이즈 지정
    maxWidthOrHeight: 1920, // 허용하는 최대 width, height 값 지정
    useWebWorker: true // webworker 사용 여부
  }
  try {
    const compressedFile = await imageCompression(imageFile, options);
    // 압축된 이미지 리턴
    await uploadToServer(compressedFile); // 블라블라
  } catch (error) {
    console.log(error);
  }

}

옵션으로 사용할 수 있는 값들은 아래와 같다.
(테크 스펙에서도 썼으니까 여긴 주석 그대로 가져오겠음)

const options: Options = { 
  maxSizeMB: number,            // (default: Number.POSITIVE_INFINITY)
  maxWidthOrHeight: number,     // compressedFile will scale down by ratio to a point that width or height is smaller than maxWidthOrHeight (default: undefined)
                                // but, automatically reduce the size to smaller than the maximum Canvas size supported by each browser.
                                // Please check the Caveat part for details.
  onProgress: Function,         // optional, a function takes one progress argument (percentage from 0 to 100) 
  useWebWorker: boolean,        // optional, use multi-thread web worker, fallback to run in main-thread (default: true)

  signal: AbortSignal,          // options, to abort / cancel the compression

  // following options are for advanced users
  maxIteration: number,         // optional, max number of iteration to compress the image (default: 10)
  exifOrientation: number,      // optional, see https://stackoverflow.com/a/32490603/10395024
  fileType: string,             // optional, fileType override e.g., 'image/jpeg', 'image/png' (default: file.type)
  initialQuality: number,       // optional, initial quality value between 0 and 1 (default: 1)
  alwaysKeepResolution: boolean // optional, only reduce quality, always keep width and height (default: false)
}

결과는 이렇다.

(압축 전 - 15.9MB)

(압축 후 - 427KB)

결과물을 보니까 거의 달라진게 없어보여서
내부에서 이미지를 어떻게 압축하고 처리하는지 궁금했다.

지금부터는 코드를 하나씩 따라가면서 간단한 원리(?)를 찾아본 내용!

이미지를 압축하기 위해서는
Browser-image-compression 라이브러리에 있는 imageCompression 이라는 메소드를 사용하게 된다.

imageCompression(file: File, options: Options): Promise<File>

imageCompression 함수 안에서는
지정된 options 값에 따라 압축하는 로직이 처리된다.

async function imageCompression(file, options) {
  const opts = { ...options };

  let compressedFile;
  let progress = 0; // 진행상황 초기값
  const { onProgress } = opts; // 옵션의 onPregress 함수는 진행상황을 0부터 100까지 퍼센트로 나타낸다.

  opts.maxSizeMB = opts.maxSizeMB || Number.POSITIVE_INFINITY;
  // maxSize를 지정하지 않으면 디폴트 값은 무한대로 지정된다.
  const useWebWorker = typeof opts.useWebWorker === 'boolean' ? opts.useWebWorker : true;
  delete opts.useWebWorker;
  opts.onProgress = (aProgress) => {
    progress = aProgress;
    if (typeof onProgress === 'function') {
      onProgress(progress);
    }
  };

  // 처리 가능한 파일 형식인지 확인하고,
  if (!(file instanceof Blob || file instanceof CustomFile)) {
    throw new Error('The file given is not an instance of Blob or File');
  } else if (!/^image/.test(file.type)) {
    throw new Error('The file given is not an image');
  }

  // web worker에서 실행 시도
  const inWebWorker = typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope;

  if (process.env.BUILD === 'development') {
    if ((useWebWorker && typeof Worker === 'function') || inWebWorker) {
      console.log('run compression in web worker');
    } else {
      console.log('run compression in main thread');
    }
  }

  if (useWebWorker && typeof Worker === 'function' && !inWebWorker) {
    try {
      // "compressOnWebWorker" is kind of like a recursion to call "imageCompression" again inside web worker
      compressedFile = await compressOnWebWorker(file, opts);
    } catch (e) {
      if (process.env.BUILD === 'development') {
        console.warn('Run compression in web worker failed:', e, ', fall back to main thread');
      }
      
      // compress라는 내부 함수를 사용한다.
      compressedFile = await compress(file, opts);
    }
  } else {
    compressedFile = await compress(file, opts);
  }

  try {
    compressedFile.name = file.name;
    compressedFile.lastModified = file.lastModified;
  } catch (e) {
    if (process.env.BUILD === 'development') {
      console.error(e);
    }
  }

  return compressedFile;
}

위에서 사용했던 compress 라는 함수를 찾아보자.

export default async function compress(file, options, previousProgress = 0) {
  let progress = previousProgress;

  // progress 쪽은 생략...
  // 대충 진행상황을 의미하는 변수 값을 5 단위로 업데이트한다.
  // (아래 로직의 중간중간에 incProgress() 함수가 실행되는 곳이 있다)
  function incProgress(inc = 5) {
    ....
  }

  const maxSizeByte = options.maxSizeMB * 1024 * 1024;

  // drawFileInCanvas 함수 소환
  const [, origCanvas] = await drawFileInCanvas(file, options);

  // handleMaxWidthOrHeight
  const maxWidthOrHeightFixedCanvas = handleMaxWidthOrHeight(origCanvas, options);
    ...

drawFileInCanvas 라는 함수는 파일과 옵션 정보를 받아서
현재의 브라우저가 IOS 혹은 사파리인지 확인한 후,
아니라면 createImageBitmap() 메소드를 통해 이미지 소스를 비트맵 형식으로 받아오고, 실패하면 dataUrl 형식으로 로드하여 가져온다.

그렇게 받아온 이미지를 파일 타입과 함께 drawImageInCanvas 라는 함수에 던지는데,
이 함수는 이미지의 width와 height 값을 조정한다.

export function drawImageInCanvas(img, fileType = undefined) {
  const { width, height } = approximateBelowMaximumCanvasSizeOfBrowser(img.width, img.height);
  const [canvas, ctx] = getNewCanvasAndCtx(width, height);
  if (fileType && /jpe?g/.test(fileType)) {
    ctx.fillStyle = 'white'; // to fill the transparent background with white color for png file in jpeg extension
    ctx.fillRect(0, 0, canvas.width, canvas.height);
  }
  ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
  return canvas;
}

approximateBelowMaximumCanvasSizeOfBrowser 함수는 이미지의 최초 width, height 값을 받고,
이를 통해 이미지 사이즈와 비율을 구한 후
브라우저에 따른 MAX_CANVAS_SIZE를 가지고 이미지 사이즈가 MAX_CAMVAS_SIZE보다 작아질 때까지 비율에 맞게 width와 height를 조정한다.
그리고 이렇게 조정된 width, height 값을 리턴한다.

export function approximateBelowMaximumCanvasSizeOfBrowser(initWidth, initHeight) {
  const browserName = getBrowserName();
  const maximumCanvasSize = MAX_CANVAS_SIZE[browserName];

  let width = initWidth;
  let height = initHeight;
  let size = width * height;
  const ratio = width > height ? height / width : width / height;

  while (size > maximumCanvasSize * maximumCanvasSize) {
    const halfSizeWidth = (maximumCanvasSize + width) / 2;
    const halfSizeHeight = (maximumCanvasSize + height) / 2;
    if (halfSizeWidth < halfSizeHeight) {
      height = halfSizeHeight;
      width = halfSizeHeight * ratio;
    } else {
      height = halfSizeWidth * ratio;
      width = halfSizeWidth;
    }

    size = width * height;
  }

  return {
    width, height,
  };
}

다시 위에 있는 drawImageInCanvas 함수로 돌아가 보면,
브라우저의 canvas 사이즈보다 작게 비율에 맞춰 조정된 가로 세로 길이를 가지고
getNewCanvasAndCtx 라는 함수 안에서 새로운 캔버스 객체를 생성해
해당 사이즈에 맞게 이미지를 그린다.

export function drawImageInCanvas(img, fileType = undefined) {
  const { width, height } = approximateBelowMaximumCanvasSizeOfBrowser(img.width, img.height);
  const [canvas, ctx] = getNewCanvasAndCtx(width, height);
  if (fileType && /jpe?g/.test(fileType)) {
    ctx.fillStyle = 'white'; // to fill the transparent background with white color for png file in jpeg extension
    ctx.fillRect(0, 0, canvas.width, canvas.height);
  }
  ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
  return canvas;
}

이렇게 생성된 canvas를 리턴하고 나면,
다시 사용자가 options의 값으로 넘긴 maxWidthOrHeight 값에 따라
이미지 사이즈를 조정하는 작업을 하게 된다.

(위에서 계속)

  let quality = options.initialQuality || 1.0;
  const outputFileType = options.fileType || file.type;

  // 옵션에서 지정한 quality와 파일 정보를 이용해 임시 파일을 먼저 생성한다.
  const tempFile = await canvasToFile(orientationFixedCanvas, outputFileType, file.name, file.lastModified, quality);

  // 임시 파일의 사이즈가
  // 사용자가 옵션으로 설정한 maxSizeByte보다 크거나
  // 원본 파일보다 커진 경우
  const origExceedMaxSize = tempFile.size > maxSizeByte;
  const sizeBecomeLarger = tempFile.size > file.size;

  // 위의 경우라면 추가로 압축을 해야 한다.
  if (!origExceedMaxSize && !sizeBecomeLarger) {
    // 아니면 패스
    // 진행상황을 100%로 업데이트하고 파일을 리턴한다.
    setProgress(100);
    return tempFile;
  }

  const sourceSize = file.size;
  const renderedSize = tempFile.size;
  let currentSize = renderedSize;
  let compressedFile;
  let newCanvas;
  let ctx;
  let canvas = orientationFixedCanvas;

  // 옵션에 있는 alwaysKeepResolution 값이 false이고, 
  // 현재 가공된 파일이 사용자가 지정한 옵션의 maxSize보다 클 때
  const shouldReduceResolution = !options.alwaysKeepResolution && origExceedMaxSize;
  while (remainingTrials-- && (currentSize > maxSizeByte || currentSize > sourceSize)) {
    // 사이즈가 작아질 때까지 width, height에 0.95씩 축소한다.
    const newWidth = shouldReduceResolution ? canvas.width * 0.95 : canvas.width;
    const newHeight = shouldReduceResolution ? canvas.height * 0.95 : canvas.height;
    [newCanvas, ctx] = getNewCanvasAndCtx(newWidth, newHeight);

    ctx.drawImage(canvas, 0, 0, newWidth, newHeight);

    quality *= 0.95;

    // 완성된 사이즈로 압축된 파일을 생성
    compressedFile = await canvasToFile(newCanvas, outputFileType, file.name, file.lastModified, quality);

  ... (생략)
  setProgress(100);
  return compressedFile;
}

우아 길다

중간중간 스킵해서 본 부분도 있고, 완전 세세하게 코드를 한줄 한줄 다 보진 못했지만
결국 보면 이미지 사이즈를 압축하고 해당 사이즈로 canvas에 이미지를 다시 렌더링해서
압축된 새 이미지를 얻어오는 원리인 것 같다.

더 자세한 소스는 깃헙에 있으니까 궁금한 분들은 참고하세요!
(혹시 위에서 틀린 부분이 있으면 알려주세용)

실제로 지금 하고 있는 에디터 상의 addImage 핸들러에다가
간단한 옵션을 주고 테스트해보니 잘 동작한다.

아직 확정은 아니지만
이 라이브러리를 사용하고 혹시 다른 이슈가 생긴다면 그때 다시 트러블 슈팅 해볼 예정!



참고 :
Browser-image-compression (https://www.npmjs.com/package/browser-image-compression)

profile
파닥파닥 FE 개발자의 기록용 블로그

0개의 댓글