Navigation

Thumbnails from PDF and Video

업로드 화면에서 PDF와 영상의 미리보기 이미지를 브라우저에서 만드는 방법을 예전에 조사해 정리한 적이 있습니다. 그때 코드는 캔버스를 base64 data URL로 바꾼 뒤 다시 File로 되돌리는 구조였고 메모리와 CORS는 거의 다루지 않았습니다. 이미지 크롭 화면에서 브라우저가 멈췄던 문제를 고친 경험을 더하고 pdfjs-dist 6.4.299 타입 정의와 MDN 문서로 다시 확인해 정리했습니다.

출력은 캔버스에서 Blob과 object URL로 냅니다

원본이 PDF든 영상이든 이미지든 마지막 단계는 같습니다. 원하는 장면을 출력 크기의 캔버스에 그리고 그 캔버스를 Blob으로 바꿉니다. 업로드에는 Blob을 감싼 File을 쓰고 미리보기에는 object URL을 씁니다.

flowchart LR
    Pdf["PDF"] -->|pdf.js 렌더링| Canvas["출력 크기 캔버스"]
    Video["영상"] -->|seek 뒤 drawImage| Canvas
    Image["이미지"] -->|보이는 영역만 drawImage| Canvas
    Canvas -->|toBlob| Blob["Blob"]
    Blob --> Upload["File로 감싸 업로드"]
    Blob -->|createObjectURL| Preview["미리보기 img"]
    Preview -->|화면에서 빠질 때 revokeObjectURL| Free["해제"]

예전 코드는 toDataURL()로 만든 문자열을 잘라 atob()로 디코드하고 바이트 배열에 옮긴 뒤 File을 만들었습니다. 같은 이미지를 문자열, 바이트 배열, File로 차례로 만든 셈입니다. MDN은 toDataURL()이 이미지 전체를 메모리 안의 문자열로 인코딩하므로 큰 이미지에서 성능 문제가 생길 수 있고 img.src에 넣으면 URL 길이 제한을 넘을 수도 있다고 설명합니다. MDN이 toBlob()과 URL.createObjectURL()의 조합을 권하는 이유입니다. toDataURL()

toBlob()은 콜백 방식이라 Promise로 감쌉니다. 이미지를 만들 수 없으면 콜백에 null이 오고 요청한 형식을 지원하지 않으면 PNG로 나옵니다. File의 type은 요청값 대신 실제 blob.type을 따릅니다. toBlob()

캔버스를 업로드할 File로 바꿉니다ts
export function canvasToFile(
  canvas: HTMLCanvasElement,
  baseName: string,
  type: 'image/jpeg' | 'image/png' | 'image/webp' = 'image/jpeg',
  quality = 0.85,
): Promise<File> {
  return new Promise((resolve, reject) => {
    canvas.toBlob(
      (blob) => {
        if (!blob) return reject(new Error('썸네일 이미지를 만들지 못했습니다'))
        // 요청한 형식을 지원하지 않으면 PNG가 오므로 실제 type으로 확장자를 정합니다
        const extension = blob.type.split('/')[1]
        resolve(new File([blob], `${baseName}.${extension}`, { type: blob.type }))
      },
      type,
      quality, // JPEG와 WebP처럼 손실 압축 형식에만 쓰입니다
    )
  })
}

object URL은 만들 때마다 새 URL이 생기고 URL이 살아 있는 동안 원래 Blob은 가비지 컬렉션되지 않습니다. 브라우저는 문서를 내릴 때 자동으로 해제하지만 SPA는 문서를 거의 내리지 않습니다. 그래서 미리보기가 화면에서 빠질 때 URL.revokeObjectURL()을 직접 부릅니다. 반대로 이미지가 로드되자마자 해제하면 사용자가 새 탭으로 열거나 저장할 수 없어서 MDN은 이를 피해야 할 패턴으로 듭니다. blob URL

미리보기가 사라질 때 object URL을 해제합니다ts
import { useEffect, useState } from 'react'

export function useObjectUrl(blob: Blob | null) {
  const [url, setUrl] = useState<string | null>(null)
  useEffect(() => {
    if (!blob) return setUrl(null)
    const next = URL.createObjectURL(blob)
    setUrl(next)
    return () => URL.revokeObjectURL(next)
  }, [blob])
  return url
}

출력 크기의 캔버스에 보이는 영역만 그립니다

운영 백오피스와 사용자 서비스에는 커버, 썸네일, 프로필 이미지를 잘라 올리는 화면이 있었습니다. 큰 사진을 고르면 크롭 중에 브라우저가 멈췄습니다. 기존 코드는 원본 크기의 캔버스에 이미지 전체를 그린 뒤 getImageData()와 putImageData()로 필요한 부분을 잘라 냈습니다. 원본이 클수록 캔버스와 픽셀 배열이 모두 원본 크기로 잡히는 구조였습니다.

크롭 영역만 최종 출력 크기의 캔버스에 그리도록 바꿨습니다. drawImage()의 9인자 형태는 원본에서 읽을 사각형과 캔버스에 그릴 사각형을 따로 받으므로 자르기와 크기 조정이 한 번에 끝납니다. 원본은 data URL 대신 object URL로 크롭 화면에 넘겼습니다. 메모리 사용량과 처리 시간은 따로 측정하지 않아 개선 폭을 숫자로 말하지는 않습니다. 당시 크롭 흐름에는 object URL 해제 코드가 없었는데 아래 예제에는 크롭 화면을 닫을 때 해제하는 위치를 넣었습니다.

보이는 영역만 출력 크기로 그립니다ts
type Rect = { x: number; y: number; width: number; height: number }

export function cropToCanvas(
  image: HTMLImageElement,
  crop: Rect, // 원본 픽셀 기준의 크롭 영역
  display: { width: number; height: number }, // 화면에 보일 CSS 크기
  density = 2,
): HTMLCanvasElement {
  const canvas = document.createElement('canvas')
  canvas.width = Math.round(display.width * density)
  canvas.height = Math.round(display.height * density)
  const context = canvas.getContext('2d')
  if (!context) throw new Error('2D 컨텍스트를 만들 수 없습니다')
  context.drawImage(
    image,
    crop.x, crop.y, crop.width, crop.height, // 원본에서 읽을 영역
    0, 0, canvas.width, canvas.height, // 출력 캔버스 전체
  )
  return canvas
}

// 크롭 화면을 열 때 const sourceUrl = URL.createObjectURL(file)
// 크롭 화면을 닫을 때 URL.revokeObjectURL(sourceUrl)

출력 크기는 표시 크기의 2배 안팎으로 잡았습니다. 업로드한 이미지는 만든 사람의 화면이 아니라 보는 사람의 고밀도 화면에서 보이므로 devicePixelRatio 대신 고정 배수를 썼습니다. 프로필 이미지가 흐리게 올라가는 문제를 고칠 때 업로드 리사이즈 기준을 2배로 올리고 크롭 출력도 키웠습니다. 다만 표시 크기 94px에 크롭 출력 180px, 업로드 리사이즈 360px로 기준이 둘로 나뉘어 업로드 단계에서 한 번 확대되는 경로가 남았습니다. 출력 크기를 정하는 곳은 한 군데여야 했습니다.

배수를 키울 때는 캔버스 한도도 봅니다. MDN에 따르면 최대 캔버스 크기는 브라우저와 환경마다 다르고 iOS 기기는 4096 x 4096 픽셀로 제한됩니다. 한도를 넘으면 캔버스를 쓸 수 없고 그리기 명령도 동작하지 않습니다. canvas 요소의 최대 크기

pdf.js로 첫 페이지를 그립니다

2026-10-25 기준 npm의 최신 pdfjs-dist는 6.4.299이고 빌드 파일은 모두 ESM(.mjs)입니다. 타입 정의의 주석은 GlobalWorkerOptions.workerSrc를 항상 지정하라고 적습니다. pdf.js FAQ는 라이브러리와 worker 파일의 버전이 같아야 한다고 하므로 worker도 같은 패키지에서 가져옵니다. Vite라면 ?url 접미사로 패키지 파일의 URL을 받을 수 있습니다. Vite 정적 자원

렌더링 인자도 바뀌었습니다. 공식 예제는 아직 canvasContext와 transform을 넘기지만 6.4.299의 RenderParameters는 canvas를 권장하고 canvasContext를 하위 호환용으로 남겼습니다. 정리 방법도 확인했습니다. 이 버전의 PDFDocumentProxy에는 destroy()가 없고 PDFDocumentLoadingTask.destroy()가 네트워크 요청을 중단하고 worker를 정리합니다.

PDF 첫 페이지를 목표 너비로 렌더링합니다ts
import { GlobalWorkerOptions, getDocument } from 'pdfjs-dist'
import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'

GlobalWorkerOptions.workerSrc = workerUrl

export async function renderPdfFirstPage(file: File, displayWidth: number, density = 2) {
  // TypedArray는 worker로 옮겨지며 소유권도 함께 넘어갑니다
  const loadingTask = getDocument({ data: new Uint8Array(await file.arrayBuffer()) })
  try {
    const pdf = await loadingTask.promise
    const page = await pdf.getPage(1)
    const base = page.getViewport({ scale: 1 })
    const viewport = page.getViewport({ scale: (displayWidth * density) / base.width })

    const canvas = document.createElement('canvas')
    canvas.width = Math.floor(viewport.width)
    canvas.height = Math.floor(viewport.height)
    await page.render({ canvas, viewport }).promise
    page.cleanup()
    return canvas
  } finally {
    await loadingTask.destroy() // 네트워크 요청을 끊고 worker를 정리합니다
  }
}

원격 PDF는 getDocument({ url })로 열 수 있지만 pdf.js도 다른 JavaScript와 같은 권한으로 요청하므로 다른 출처의 파일은 CORS가 허용돼야 합니다. FAQ는 대안으로 CORS 설정과 서버 쪽 프록시를 듭니다. 메모리도 계산해 둡니다. FAQ는 96 DPI에서 레터 크기 한 페이지(816 x 1056 픽셀)를 그리는 데 캔버스 메모리가 약 3.5MB, 고밀도 화면이면 약 14MB 든다고 설명합니다. 여러 페이지의 썸네일이 필요하면 보이는 페이지만 그리고 다 쓴 캔버스는 참조를 끊습니다. pdf.js FAQ

video 요소로 원하는 시점의 프레임을 잡습니다

영상은 <video> 요소를 화면에 붙이지 않은 채 메타데이터를 읽고 원하는 시점으로 이동한 뒤 그 프레임을 캔버스에 그립니다. 순서가 중요합니다.

영상의 한 시점을 캔버스로 옮깁니다ts
export async function captureVideoFrame(source: File | string, ratio = 0.1, width = 640) {
  const video = document.createElement('video')
  const url = typeof source === 'string' ? source : URL.createObjectURL(source)
  video.crossOrigin = 'anonymous' // 원격 영상은 src보다 먼저 지정합니다
  try {
    await once(video, 'loadedmetadata', () => { video.src = url })
    if (!Number.isFinite(video.duration)) throw new Error('길이를 알 수 없는 영상입니다')
    await once(video, 'seeked', () => { video.currentTime = video.duration * ratio })

    const canvas = document.createElement('canvas')
    canvas.width = width
    canvas.height = Math.round((width * video.videoHeight) / video.videoWidth)
    const context = canvas.getContext('2d')
    if (!context) throw new Error('2D 컨텍스트를 만들 수 없습니다')
    context.drawImage(video, 0, 0, canvas.width, canvas.height)
    return { canvas, duration: video.duration }
  } finally {
    video.removeAttribute('src')
    video.load()
    if (typeof source !== 'string') URL.revokeObjectURL(url)
  }
}

function once(media: HTMLMediaElement, type: string, start: () => void) {
  return new Promise<void>((resolve, reject) => {
    media.addEventListener(type, () => resolve(), { once: true })
    media.addEventListener('error', () => reject(media.error), { once: true })
    start()
  })
}

예전 코드는 영상 길이의 절반 지점을 썼습니다. 어느 시점이 대표 장면인지는 서비스마다 다르므로 비율을 인자로 뺐습니다. 재생 중에 여러 장을 뽑아야 한다면 requestVideoFrameCallback()을 검토합니다. 새 프레임이 컴포지터로 전달될 때 콜백을 부르고 그 프레임의 mediaTime을 주므로 timeupdate보다 정확한 시점을 얻습니다. MDN은 이 API를 2024년 10월부터 Baseline으로 표시합니다. requestVideoFrameCallback()

메모리와 CORS에서 자주 막히는 지점

증상원인대응
toBlob()에서 SecurityErrorCORS 승인 없이 받은 원격 영상이나 이미지를 그려 캔버스가 오염됨요소에 crossOrigin 지정, 서버의 Access-Control-Allow-Origin 확인
PDF를 불러오지 못함다른 출처의 파일을 CORS 없이 요청파일 서버의 CORS 설정 또는 같은 출처 프록시
캔버스가 비어 있음최대 캔버스 크기 초과출력 크기에 상한 적용
메모리가 계속 늘어남해제하지 않은 object URL, 정리하지 않은 pdf.js workerrevokeObjectURL(), loadingTask.destroy()
toBlob() 콜백에 null이미지를 만들 수 없는 상태오류로 처리하고 썸네일 없이 진행할지 결정
요청과 다른 PNG가 나옴그 형식의 인코딩을 지원하지 않는 브라우저blob.type으로 확장자와 MIME 결정
worker 관련 오류라이브러리와 worker 파일의 버전 불일치worker URL을 같은 패키지에서 가져오기

MDN은 캔버스 오염을 이렇게 설명합니다. 다른 출처에서 CORS 승인 없이 불러온 데이터를 캔버스에 그리는 순간 캔버스가 오염되고 이후 getImageData(), toBlob(), toDataURL(), captureStream()이 SecurityError를 냅니다. 허락 없이 원격 사이트의 정보를 읽어 내지 못하게 사용자를 보호하는 장치라서 클라이언트 코드만으로는 우회할 수 없고 파일 서버의 응답 헤더가 필요합니다. Use cross-origin images in a canvas

검증 범위도 적어 둡니다. 크롭 개선은 멈추던 동작을 고친 것이고 수치로 측정한 결과는 없습니다. 효과를 숫자로 말하려면 같은 원본 파일로 변경 전후를 Performance 패널에 기록해 메인 스레드 작업 시간을 비교해야 합니다. pdf.js의 API 설명은 6.4.299 타입 정의 기준이라 메이저 버전이 바뀌면 다시 확인해야 합니다.

참고 자료