iOS Safari에서 올바르게 비디오 썸네일 생성하기 🔍

iOS Safari에서 비디오 썸네일을 생성하는 방법과 그 이유 (온라인 데모 포함)

Table of Contents

English

참고: iOS Safari 14 ~ 17.4.1 버전에서 동작을 확인했습니다.

비디오 썸네일에 대한 NPM 패키지를 찾으신다면, generate-video-dumbnail은 어떠신가요? 여기서 온라인 데모를 확인해 보세요.

썸네일 이슈 iOS에서 비디오 썸네일 생성은 예상과 다르게 동작하는 경우가 있습니다

최근 비디오 썸네일 생성 작업을 수행했었습니다. 어렵지는 않았습니다. 약간의 추론과, ChatGPT, 그리고 구글링을 통해 빠르게 코드를 구성해 나갔습니다. 그렇게 완성한 코드는 macOS Safari와 Chrome 브라우저에서 예상과 같이 동작했습니다. 생각보다 일찍 끝날 것 같아 조금 들떠 있기도 했습니다. 그리고, iOS Safari에서 비디오 썸네일을 생성해 봤는데… 아무것도 나타나지 않았습니다! 무언가 잘못되었죠. 🤯

브라우저 스펙 테스트 실패 그래프 브라우저 스펙 테스트 실패 그래프 (데이터 소스)

이와 비슷한 상황을 겪어보신 적이 있나요? iOS Safari는 매끄럽고 우아하지만, 종종 웹 프런트엔드 개발자에게 큰 고통을 주기도 합니다. 이 글에서는 iOS Safari에서 비디오 썸네일을 생성하는 방법과 그 이유 에 대해 정리한 내용을 공유하고자 합니다. 또한 실제 기기에서 테스트할 수 있도록 온라인 데모도 함께 제공합니다.

썸네일 문제가 얼마나 오래 지속될지는 모르겠지만, 적어도 지금은 이 가이드를 통해 의도한 대로 생성할 수 있습니다. 이 내용이 도움이 되기를 바랍니다.

들어가기 전에

이 글은 순서가 없습니다! 위에서부터 순서대로 읽는 대신, 필요한 내용에 바로 접근해 읽을 수 있습니다. 돋보기(🔍)가 없는 제목은 각 상황을 의미합니다. 내용은 해결 방법(코드)을 먼저 보여주고, 이에 대한 이유를 그다음에 간략하게 제공합니다. 이유 앞에는 돋보기(🔍)가 있는데, 이를 클릭하면 상세한 설명을 볼 수 있습니다. 또한 iOS Safari를 대상으로만 설명합니다.

<video
  src="https://cdn.jsdelivr.net/gh/Gumball12/public-timer-mp4/timer.mp4#t=0.001"
  preload="metadata"
></video>

CodePen

  • 🔍 src 애트리뷰트 마지막에 #t=0.001을 붙여 영상 첫 프레임을 표시할 수 있습니다.
  • 🔍 preload 애트리뷰트 값을 metadata로 설정할 필요는 없지만, 썸네일 생성이 지연될 수 있습니다.
  • 비디오는 HTTP 또는 HTTPS로 제공할 수 있습니다.

iOS Safari는 비디오 요소에 대해 기본적으로 첫 번째 프레임을 보여주지 않습니다! preload="metadata" 또는 preload="auto"로 설정해도 마찬가지입니다. 이 대신, Media Fragments URI를 사용해야 합니다. (Can I Use)

#t=<time>은 Media Fragments 요소 중 하나입니다. 이를 이용해 비디오 특정 시간을 지정할 수 있습니다(초 단위). iOS Safari에서는 이 방식을 이용해 비디오 프레임(포스터)을 보여줄 수 있습니다. 가령 #t=0.001로 설정한다면(0.001초), 첫 번째 비디오 프레임이 포스터로 나타납니다. 참고로 #t=0은 동작하지 않는다는 점을 유의하세요.

메타데이터 로드 시, 비디오 프레임 일부가 전달될 수 있습니다. 따라서 preload 속성을 'auto' 대신 'metadata'로 설정할 수 있습니다. 참고로 iOS Safari에서 preload 속성 기본값은 'auto' 이지만, 디바이스 상태에 따라(예: 배터리 절약 모드) 달라질 수 있습니다. 이러한 이유로, 속성값을 명시적으로 설정하는 것이 좋습니다.

w/ 동적 비디오 요소 (HTTP URL)

Permalink to “w/ 동적 비디오 요소 (HTTP URL)”
const video = document.createElement('video');
document.body.appendChild(video);

video.preload = 'metadata';
video.src =
  'https://cdn.jsdelivr.net/gh/Gumball12/public-timer-mp4/timer.mp4#t=0.001';

CodePen

  • 🔍 src 애트리뷰트 마지막에 #t=0.001을 붙여 영상 첫 프레임을 표시할 수 있습니다.
  • 🔍 preload 애트리뷰트 값을 metadata로 설정할 필요는 없지만, 썸네일 생성이 지연될 수 있습니다.
  • 비디오 요소는 항상 HTTPS로 제공해야 합니다. 보안상 이유로, 동적으로 비디오 요소를 만드는 경우 src 애트리뷰트는 항상 HTTPS를 사용해야 합니다.

w/ 동적 비디오 요소 (Blob URL)

Permalink to “w/ 동적 비디오 요소 (Blob URL)”
const input = document.createElement('input');
input.type = 'file';
input.accept = 'video/*';

document.body.appendChild(input);

input.addEventListener('change', ({ target }) => {
  const file = target.files[0];
  const url = URL.createObjectURL(file);

  const video = document.createElement('video');
  document.body.appendChild(video);

  video.src = url;
  video.preload = 'metadata';
  video.currentTime = 0.001;
});

CodePen

  • 🔍 Blob URL 사용 시, preload는 반드시 'metadata'여야 합니다. 'auto'는 동작하지 않습니다.
  • 🔍 Media Fragments URI(#t=<time>)는 Blob URL에 대해 동작하지 않습니다. 이 대신 'loadedmetadata' 이벤트를 이용해 currentTime0.001로 설정하는 방식을 이용해 주세요.

🔍 Blob URL 사용 시 preload='metadata'로 설정

Permalink to “🔍 Blob URL 사용 시 preload='metadata'로 설정”

Preload 비교 - metadata Preload 비교 - auto preload = 'metadata'preload = 'auto' 네트워크 요청 비교

Blob URL을 사용해 비디오 요소를 생성하는 경우, preload'auto'로 설정하면 지정된 시간에 대한 프레임 데이터를 가져오지 않습니다. 실제로 네트워크 요청(위 이미지)을 확인해 보면, preload="metadata"는 첫 번째 프레임을 나타내기 위해 48-1541 범위만큼 데이터(Bytes)를 가져오는 것을 볼 수 있습니다. 반면 preload="auto"는 데이터를 가져오지 않았습니다.

안타깝게도 이러한 차이에 대한 기술적인 이유는 찾지 못했습니다. 테스트는 iOS Safari 17.4.1에서 이 코드를 통해 진행했습니다.

🔍 Blob URL은 Media Fragments URI를 사용할 수 없음

Permalink to “🔍 Blob URL은 Media Fragments URI를 사용할 수 없음”

현재 최신 WebKit(Safari 17.4.1)에서는 Blob URL에 Media Fragments URI(예: #t=<time>)을 사용할 수 없습니다. 관련 변경 사항은 이 커밋[1]에서 확인할 수 있습니다. 아래는 커밋 내용 중 일부입니다:

BlobData* BlobRegistryImpl::getBlobDataFromURL(const URL& url, const std::optional<SecurityOriginData>& topOrigin) const
{
    ASSERT(isMainThread());
-    if (url.hasFragmentIdentifier())
-        return m_blobs.get<StringViewHashTranslator>(url.viewWithoutFragmentIdentifier());
-    return m_blobs.get(url.string());
+    auto urlKey = url.stringWithoutFragmentIdentifier();
+    auto* blobData = m_blobs.get(urlKey);
+    if (m_allowedBlobURLTopOrigins && topOrigin && topOrigin != m_allowedBlobURLTopOrigins->get(urlKey)) {
+        RELEASE_LOG_ERROR(Network, "BlobRegistryImpl::getBlobDataFromURL: (%p) Requested blob URL with incorrect top origin.", this);
+        return nullptr;
+    }
+    return blobData;
}

Media Fragment URI 사용 여부를 확인하는 URL::hasFragmentIdentifier 함수가 제거되었습니다. 대신, Media Fragment URI를 제외한 문자열을 반환하는 URL::stringWithoutFragmentIdentifier 함수가 사용됩니다. 따라서 Media Fragment URI 대신 currentTime을 사용해야 합니다.

코드 히스토리도 살펴보면, 이전에는 Media Fragment URI가 Blob URL에 대해서도 지원되었음을 확인할 수 있습니다. 이는 Safari Technology Preview 118 릴리즈 노트관련 커밋에서 확인할 수 있습니다.

const THUMBNAIL_POSITION = 5.3;

const video = document.createElement('video');
video.crossOrigin = 'anonymous';
video.preload = 'metadata';
video.src = `https://cdn.jsdelivr.net/gh/Gumball12/public-timer-mp4/timer.mp4#t=${THUMBNAIL_POSITION}`;

video.addEventListener('seeked', () => {
  const canvas = document.createElement('canvas');
  canvas.width = video.videoWidth;
  canvas.height = video.videoHeight;

  const ctx = canvas.getContext('2d');
  ctx.drawImage(video, 0, 0, canvas.width, canvas.height);

  canvas.toBlob(blob => {
    const img = document.createElement('img');
    img.src = URL.createObjectURL(blob);
    document.body.appendChild(img);
  });
});

CodePen

  • 🔍 crossOrigin 애트리뷰트는 'anonymous'로 설정해야 합니다.
  • 🔍 preload 애트리뷰트 값을 metadata로 설정할 필요는 없지만, 썸네일 생성이 지연될 수 있습니다.
  • 🔍 비디오 프레임 데이터는 'seeked' 또는 'timeupdate' 이벤트 이후에 사용할 수 있습니다.

🔍 crossOrigin='anonymous'로 설정해 캔버스 요소에 대한 SecurityError 피하기

Permalink to “🔍 crossOrigin='anonymous'로 설정해 캔버스 요소에 대한 SecurityError 피하기”

브라우저는 기본적으로 "동일한 출처"를 가진 리소스만 허용합니다(SOP, Same Origin Policy). 출처가 다르다면 CORS(Cross-Origin Resource Sharing)를 통해 접근을 허용받아야 합니다.

미디어 리소스 요청 시 crossOrigin 애트리뷰트를 이용해 CORS 정책을 어떻게 처리할지 결정할 수 있습니다. 물론 아무런 설정 없이도 다른 출처(Origin)에 존재하는 미디어 리소스를 요청할 수는 있습니다. 하지만 브라우저는 무결성이 보장되지 않는다고 간주합니다. 그리고 이를 캔버스에 그리게 되면, 오염된(Tainted) 캔버스 가 됩니다[2].

오염된 캔버스

오염된 캔버스는 웹사이트가 리소스 공유를 허용받지 못했음을 의미합니다. 이 상태에서는 픽셀 데이터에 접근할 수 없습니다. 그렇지 않으면 허용되지 않은 출처에서 이미지 내 픽셀 데이터를 임의로 유출할 수 있게 됩니다.

캔버스 픽셀 데이터는 getImageData, toDataURL, toBlob 등으로 접근할 수 있습니다. 위에서 언급한 보안 문제를 방지하기 위해, 이러한 메서드는 오염된 캔버스에 대해 SecurityError를 던집니다. 그렇기에 crossOrigin 을 설정해야 합니다.

옵션은 두 가지가 있습니다. 그 중 'anonymous'(또는 빈 문자열 '')는 CORS를 검증하지만, 요청에 자격 증명(쿠키, HTTP Authentication 헤더, SSL 인증서 등)을 포함하지 않도록 합니다. 이는 민감한 사용자 정보가 노출되는 상황을 방지하는 데 도움이 됩니다. 따라서 여기서는 'anonymous'를 사용했습니다. 다만 필요한 경우 'use-credential' 옵션을 통해 자격 증명을 포함할 수 있습니다.

🔍 비디오 프레임 데이터는 'seeked' 또는 'timeupdate' 이벤트 이후 사용 가능 (HTTP URL)

Permalink to “🔍 비디오 프레임 데이터는 'seeked' 또는 'timeupdate' 이벤트 이후 사용 가능 (HTTP URL)”

HTTP URL을 사용해 비디오 데이터를 요청하는 경우, 프레임 데이터는 'seeked' 또는 'timeupdate' 이벤트 이후에 접근이 가능합니다. (⚠️ Blob URL은 다르게 동작합니다.)

const THUMBNAIL_POSITION = 5.3;

const input = document.createElement('input');
input.type = 'file';
input.accept = 'video/*';

document.body.appendChild(input);

input.addEventListener('change', async ({ target }) => {
  const file = target.files[0];
  const src = URL.createObjectURL(file);

  const video = document.createElement('video');
  video.src = src;
  video.preload = 'metadata';

  video.addEventListener('seeked', async () => {
    await new Promise(resolve => setTimeout(resolve, 100));

    const canvas = document.createElement('canvas');
    canvas.width = video.videoWidth;
    canvas.height = video.videoHeight;

    const ctx = canvas.getContext('2d');
    ctx.drawImage(video, 0, 0, canvas.width, canvas.height);

    canvas.toBlob(blob => {
      const img = document.createElement('img');
      img.src = URL.createObjectURL(blob);
      document.body.appendChild(img);
    });
  });

  video.addEventListener(
    'loadedmetadata',
    () => (video.currentTime = THUMBNAIL_POSITION),
  );
});

CodePen

  • 🔍 Blob URL 사용 시, preload는 반드시 'metadata'여야 합니다. 'auto'는 동작하지 않습니다.
  • 🔍 Media Fragments URI(#t=<time>)는 Blob URL에 대해 동작하지 않습니다. 이 대신 'loadedmetadata' 이벤트를 이용해 currentTime0.001로 설정하는 방식을 이용해 주세요.
  • 🔍 비디오 프레임 데이터는 'seeked' 또는 'timeupdate' 이벤트 이후 일정 시간이 지나야 사용할 수 있습니다.

🔍 비디오 프레임 데이터는 'seeked' 또는 'timeupdate' 이후 일정 시간이 지나야 사용 가능 (Blob URL)

Permalink to “🔍 비디오 프레임 데이터는 'seeked' 또는 'timeupdate' 이후 일정 시간이 지나야 사용 가능 (Blob URL)”

Blob URL에 대해서도 'seeked''timeupdate' 이벤트를 사용할 수 있습니다. 다만 비디오 프레임 데이터를 사용한다면 일정 시간을 기다려야 합니다. 경험적으로 80~100ms 이후 접근이 가능했습니다.

HTTP URL과 달리, Blob URL은 이벤트가 트리거 된 직후 프레임 데이터가 없을 수 있습니다. 심지어 readyState가 "HAVE_ENOUGH_DATA"를 의미하는 4 인 경우에도 말이죠! 이는 Blob URL이 로컬 파일을 참조하기 때문으로 추정됩니다[3]. 비디오 데이터가 디코딩되어 메모리에 로드될 때 지연이 발생할 수 있습니다.

const THUMBNAIL_POSITION_LIST = [
  0, 1.1, 2.2, 3.3, 4.4, 5.5, 4.6, 3.7, 2.8, 1.9, 0,
];

const main = async () => {
  for (const thumbnailPosition of THUMBNAIL_POSITION_LIST) {
    const correctedThumbnailPosition = thumbnailPosition || 0.001;

    const video = document.createElement('video');
    video.crossOrigin = 'anonymous';
    video.preload = 'metadata';
    video.src = `https://cdn.jsdelivr.net/gh/Gumball12/public-timer-mp4/timer.mp4#t=${correctedThumbnailPosition}`;

    generateThumbnail(video, thumbnailPosition);
  }
};

const generateThumbnail = (video, thumbnailPosition) => {
  const img = document.createElement('img');
  document.body.appendChild(img);

  video.addEventListener('seeked', () => {
    const canvas = document.createElement('canvas');
    canvas.width = video.videoWidth;
    canvas.height = video.videoHeight;

    const ctx = canvas.getContext('2d');
    ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
    canvas.toBlob(blob => (img.src = URL.createObjectURL(blob)));
  });

  video.currentTime = thumbnailPosition;
};

main();

CodePen

  • 🔍 crossOrigin 애트리뷰트는 'anonymous'로 설정해야 합니다.
  • 🔍 preload 애트리뷰트 값을 metadata로 설정할 필요는 없지만, 썸네일 생성이 지연될 수 있습니다.
  • 🔍 #t=${time} 을 이용해 썸네일 위치를 지정할 수 있습니다.
  • 🔍 비디오 프레임 데이터는 'seeked' 또는 'timeupdate' 이벤트 이후에 사용할 수 있습니다.
const input = document.createElement('input');
input.type = 'file';
input.accept = 'video/*';

document.body.appendChild(input);

input.addEventListener('change', async ({ target }) => {
  const file = target.files[0];
  const src = URL.createObjectURL(file);

  const THUMBNAIL_POSITION_LIST = [
    0, 1.1, 2.2, 3.3, 4.4, 5.5, 4.6, 3.7, 2.8, 1.9, 0,
  ];

  for (const thumbnailPosition of THUMBNAIL_POSITION_LIST) {
    const video = document.createElement('video');
    video.preload = 'metadata';
    video.src = src;
    await generateThumbnail(video, thumbnailPosition);
  }
});

const generateThumbnail = (video, thumbnailPosition) =>
  new Promise(resolve => {
    video.addEventListener('seeked', async () => {
      await new Promise(resolve => setTimeout(resolve, 100));

      const canvas = document.createElement('canvas');
      canvas.width = video.videoWidth;
      canvas.height = video.videoHeight;

      const ctx = canvas.getContext('2d');
      ctx.drawImage(video, 0, 0, canvas.width, canvas.height);

      canvas.toBlob(blob => {
        const img = document.createElement('img');
        img.src = URL.createObjectURL(blob);
        document.body.appendChild(img);

        resolve();
      });
    });

    video.addEventListener('loadedmetadata', () =>
      setTimeout(() => (video.currentTime = thumbnailPosition), 1),
    );
  });

CodePen

  • 🔍 Blob URL 사용 시, preload는 반드시 'metadata'여야 합니다. 'auto'는 동작하지 않습니다.
  • 🔍 스레드 블로킹을 피하기 위해, Promise를 사용해 썸네일을 순차적으로 생성해야 합니다.
  • 🔍 비디오 프레임 데이터는 'seeked' 또는 'timeupdate' 이벤트 이후 일정 시간이 지나야 사용할 수 있습니다.

🔍 스레드 블로킹으로 인해 잘못된 썸네일 생성 가능

Permalink to “🔍 스레드 블로킹으로 인해 잘못된 썸네일 생성 가능”

썸네일 여러 개를 생성하는 경우 스레드 블로킹이 발생할 수 있습니다. 그리고 만약 스레드 블로킹이 발생한다면, 썸네일이 제대로 생성되지 않을 수 있습니다. 이 경우 투명 썸네일이 생성됩니다.

썸네일 일괄 생성 시도 썸네일 일괄 생성 시도 (타임라인 원본 데이터)

위 이미지는 영상 내 모든 썸네일을 한 번에 생성하려고 시도한 결과입니다. 특정 범위에 네트워크 요청이 집중되어 있고, 메인 스레드 역시 장시간 지속적으로 사용되고 있습니다. 그 결과 스레드 블로킹이 발생되어, 투명한 썸네일이 생성되었습니다.

이를 방지하기 위해 여기서는 Promise를 사용해 순차적으로 썸네일을 생성했습니다. generateThumbnail 함수는 Promise를 반환하고, await을 사용해 각 썸네일이 생성될 때까지 기다렸습니다.

순차적으로 썸네일 생성 시도 순차적으로 썸네일 생성 시도 (타임라인 원본 데이터)

위 이미지는 썸네일을 순차적으로 생성한 결과입니다. 네트워크 요청이 고르게 분산되고, 메인 스레드가 짧은 시간 동안만 사용되었습니다. 따라서 스레드 블로킹은 발생되지 않았고, 썸네일을 올바르게 생성할 수 있었습니다.

이벤트 대신 requestVideoFrameCallback을 사용할 수도 있습니다. 다만 이 함수는 FireFox에서 지원하지 않기 때문에, 고려하지 않았습니다.

이 함수에 대해 더 알고 싶다면 web.dev를 참고해 주세요.

저는 아이폰을 사용하고, 꽤 매력적인 기기라고 생각합니다. 그러나 독특한 iOS Safari 구현은 가끔씩 예상치 못한 도전과제를 안겨주곤 합니다. 표준과 다른 동작 방식은 다소 낯설고 어려울 수 있지만, 플랫폼이 갖는 미묘한 차이점을 이해하고 극복하는 것은 사용자 겸험을 한층 더 향상시키는 중요한 요소입니다. 그리고 이를 해결해 나가는 과정에서 얻는 성취감은 그만큼 값집니다.

이번 글에서는 "iOS Safari에서 비디오 썸네일을 올바르게 생성하는 방법"에 대해 다루어 보았습니다. 다양한 접근 방식과 그에 따른 이유를 살펴보며, 실제로 적용 가능한 코드를 통해 문제를 해결하는 과정을 공유했습니다. 저는 이 글이 여러분과 여러분이 만드는 서비스에 도움이 되기를 진심으로 바랍니다. 여러분이 직면한 유사한 문제들을 해결하는 데 조금이나마 도움이 되기를 바랍니다. 읽어주셔서 감사합니다!


  1. 이 커밋은 Blob 파티셔닝과 관련된 문제를 해결하는 것으로 보입니다. Blob 파티셔닝은 큰 데이터를 효율적으로 처리하고 사용자에게 빠른 응답을 제공하기 위해 Blob을 작은 청크로 나누는 기술입니다. 이 기능은 Safari 17.2(19671.1.17)에서 소개되었습니다. ↩︎

  2. 출처가 동일하다면 SOP에 의해 캔버스가 오염되지 않습니다! ↩︎

  3. 예제 코드에서는 File 객체를 사용해 Blob URL을 생성합니다. 그리고 File 객체는 로컬 파일 시스템의 파일을 참조합니다. ("File 인터페이스는 파일에 대한 정보를 제공하고, 웹 페이지에서 JavaScript를 통해 해당 콘텐츠에 접근할 수 있도록 도와줍니다." - MDN) ↩︎