Shaka Player 파헤치기 - VTT를 통한 Thumbnail Preview TrackBar 구현

들어가며 — 트랙바 위의 작은 미리보기

영상 플레이어의 트랙바(Seek Bar) 위에 마우스를 올리면, 그 지점의 장면이 작은 이미지로 떠오르는 기능을 자주 보셨을 겁니다. 이 기능을 썸네일 프리뷰(Thumbnail Preview)라고 부릅니다. 사용자는 실제로 이동하기 전에 어느 장면으로 가게 될지 미리 확인할 수 있습니다.

Shaka Player는 이 기능을 위한 공개 API를 제공합니다. 이 글에서는 Shaka Player 5.1.0 기준으로 다음을 확인합니다.

  • addThumbnailsTrack, getThumbnails 같은 썸네일 관련 공개 API가 어떻게 동작하는지
  • 스프라이트 이미지와 VTT 파일 속 좌표 정보를 파싱해 썸네일 목록을 추려내는 로직
  • 트랙바의 특정 지점에 마우스를 올렸을 때 프리뷰 이미지가 표시되기까지의 과정

마지막에는 이 API들로 ThumbnailPreviewTrackbar React 컴포넌트를 만들어 글 안에 함께 렌더링합니다. 직접 마우스를 올려 동작을 확인해 보실 수 있습니다.

썸네일 VTT와 스프라이트 이미지

썸네일 프리뷰의 재료는 두 가지입니다.

첫째는 스프라이트 이미지입니다. 영상의 장면을 일정 간격으로 캡처해 한 장의 이미지에 격자 형태로 이어 붙인 것입니다. 썸네일마다 이미지를 따로 요청하는 대신 한 번의 요청으로 여러 장면을 받아올 수 있습니다. 이 글의 데모에서는 60초짜리 테스트 영상에서 2초 간격으로 30장면을 뽑아, 160×90 크기의 칸을 10열 3행으로 이어 붙였습니다.

60초 테스트 영상을 2초 간격으로 캡처해 10×3 격자로 이어 붙인 스프라이트 이미지

둘째는 VTT 파일입니다. 자막에 쓰는 WebVTT 형식을 그대로 빌려 쓰는데, cue의 본문에 자막 문장 대신 이미지 URL과 스프라이트 내 좌표를 적습니다. 좌표는 URL 끝에 #xywh=x,y,width,height 형식으로 붙입니다.

1WEBVTT
2
300:00:00.000 --> 00:00:02.000
4sprite.jpg#xywh=0,0,160,90
5
600:00:02.000 --> 00:00:04.000
7sprite.jpg#xywh=160,0,160,90
8
900:00:04.000 --> 00:00:06.000
10sprite.jpg#xywh=320,0,160,90
11
12...
13
1400:00:58.000 --> 00:01:00.000
15sprite.jpg#xywh=1440,180,160,90

정리하면 VTT의 cue 하나가 "이 시간 구간에는 스프라이트의 이 영역을 보여줘라"라는 의미를 갖습니다. 플레이어가 할 일은 이 파일을 읽어 두었다가, 특정 시간이 주어지면 해당하는 cue를 찾아 좌표를 돌려주는 것입니다.

공개 API 한눈에 보기

Shaka Player에서 썸네일과 관련된 공개 API는 모두 Player 클래스(lib/player.js)에 있습니다.

API역할
addThumbnailsTrack(uri, mimeType)외부 VTT 파일을 썸네일 트랙(이미지 트랙)으로 등록합니다.
getImageTracks()현재 사용할 수 있는 이미지 트랙 목록을 반환합니다.
getThumbnails(trackId, time)특정 시간에 해당하는 썸네일 하나를 반환합니다.
getAllThumbnails(trackId)트랙에 포함된 썸네일 전체 목록을 반환합니다.

getThumbnails와 getAllThumbnails는 Thumbnail 객체를 돌려줍니다. 프리뷰를 그릴 때 필요한 필드는 다음과 같습니다.

  • uris: 스프라이트 이미지 주소
  • positionX, positionY: 스프라이트 안에서 해당 장면의 위치
  • width, height: 잘라낼 크기
  • startTime: 해당 썸네일이 시작되는 시간

썸네일 API는 어떻게 동작할까

addThumbnailsTrack — VTT를 썸네일 목록으로

addThumbnailsTrack은 VTT 파일을 받아와 cue 하나를 썸네일 하나로 바꿔 저장합니다. 이때 cue 본문의 #xywh= 뒤에 적힌 값을 스프라이트 좌표로 읽어 둡니다.

1// lib/player.js — addThumbnailsTrack (발췌)
2for (const cue of cues) {
3  // ...cue의 시간 구간과 이미지 주소로 SegmentReference를 만든다
4  if (cue.payload.includes('#xywh')) {
5    const spriteInfo = cue.payload.split('#xywh=')[1].split(',');
6    if (spriteInfo.length === 4) {
7      reference.setThumbnailSprite({
8        height: parseInt(spriteInfo[3], 10),
9        positionX: parseInt(spriteInfo[0], 10),
10        positionY: parseInt(spriteInfo[1], 10),
11        width: parseInt(spriteInfo[2], 10),
12      });
13    }
14  }
15  references.push(reference);
16}
  • 썸네일 전용 파서가 따로 있지 않고, 자막을 파싱할 때 쓰는 VTT 파서를 그대로 사용합니다. 파서 입장에서 sprite.jpg#xywh=0,0,160,90은 그저 cue의 본문 텍스트입니다.
  • cue마다 영상 세그먼트를 표현할 때와 같은 SegmentReference를 하나씩 만듭니다. 썸네일도 "특정 시간 구간에 대응하는 리소스"로 다루는 것입니다.
  • VTT에 sprite.jpg처럼 상대 경로만 적어도 VTT 파일 위치를 기준으로 이미지 주소를 해석합니다.

사용할 때 주의할 조건도 두 가지 있습니다.

  • load()가 끝난 뒤에만 호출할 수 있습니다. (Load, Attach 과정에서 로드 과정을 다뤘습니다.)
  • 콘텐츠 길이가 Infinity인 라이브 스트림에서는 쓸 수 없습니다.

getThumbnails — 시간을 썸네일 좌표로

getThumbnails(trackId, time)는 등록된 썸네일 중 그 시간을 포함하는 cue를 찾아 좌표를 담은 Thumbnail을 돌려줍니다. 예를 들어 time = 13.4라면 00:00:12.000 --> 00:00:14.000 cue가 선택되고, 그 cue의 #xywh=960,0,160,90이 그대로 positionX: 960, positionY: 0, width: 160, height: 90이 됩니다. trackId로 null을 넘기면 첫 번째 이미지 트랙을 사용합니다.

INFO

DASH 매니페스트의 썸네일처럼 이미지 한 장에 여러 장면이 같은 시간 간격의 격자로 들어 있는 경우도 있습니다. 이때 Shaka는 시간으로 몇 번째 칸인지 계산해 좌표를 구합니다. 다만 VTT처럼 cue에 #xywh 좌표가 직접 적혀 있으면 그 좌표를 우선사용합니다.

getAllThumbnails — 썸네일 목록 추려내기

getAllThumbnails(trackId)는 등록된 모든 cue를 돌면서 getThumbnails와 같은 방식으로 Thumbnail을 만들어 배열로 돌려줍니다. VTT 방식에서는 cue 하나가 썸네일 하나가 되므로, cue가 30개인 데모의 VTT라면 getAllThumbnails()는 30개의 썸네일을 반환합니다. 아래 데모의 하단 썸네일 목록도 이 API로 만들었습니다.

호버하면 프리뷰가 뜨기까지

API가 좌표를 돌려주면, 화면에 그리는 일은 UI의 몫입니다. Shaka Player가 함께 제공하는 UI(ui/seek_bar.js)는 다음 순서로 프리뷰를 보여줍니다.

  1. 마우스 위치를 시간으로 바꿉니다. 트랙바에서 mousemove가 일어나면 마우스가 트랙바 전체의 몇 퍼센트 지점에 있는지 구하고, 그 비율을 재생 시간 범위에 대입합니다.
  2. 그 시간으로 getThumbnails를 호출합니다. 동시에 프리뷰 박스를 마우스 위치 중심에 두되, 트랙바 양 끝을 벗어나지 않게 위치를 고정합니다.
  3. 스프라이트에서 한 칸만 보여줍니다. 아래 코드가 그 부분입니다.
1// ui/seek_bar.js — showThumbnailAndTime_ (발췌)
2let uri = thumbnail.uris[0].split('#xywh=')[0];
3// ...
4const scale = widthImageContainer / thumbnail.width;
5this.thumbnailImage_.style.left = '-' + scale * thumbnail.positionX + 'px';
6this.thumbnailImage_.style.top = '-' + scale * thumbnail.positionY + 'px';
7this.thumbnailImage_.style.transform = 'scale(' + scale + ')';
8this.thumbnailImage_.style.transformOrigin = 'left top';
  • uris[0]에서 #xywh= 뒷부분을 떼어내 스프라이트 원본 이미지 주소를 얻습니다.
  • 프리뷰 박스 너비를 썸네일 너비로 나눠 확대 배율(scale)을 구합니다. 박스가 192px이고 썸네일이 160px이라면 scale은 1.2입니다.
  • 이미지 전체를 scale배로 키운 뒤, 좌표에도 같은 배율을 곱한 만큼 왼쪽·위쪽으로 밀어냅니다. 박스 밖으로 나간 부분을 가리면 원하는 칸만 보이게 됩니다.

직접 만들어보기 —ThumbnailPreviewTrackbar

이제 앞에서 살펴본 API로 React 컴포넌트를 만들어 보겠습니다. Shaka UI를 쓰지 않고 Player의 공개 API만으로 트랙바와 프리뷰를 직접 구현합니다.

아래 데모의 트랙바에 마우스를 올려보세요. 터치 기기에서는 트랙바를 누른 채 움직이면 됩니다. 트랙바를 클릭하거나 아래 썸네일 목록을 누르면 해당 위치로 이동하고, 영상을 클릭하면 재생과 일시정지가 전환됩니다. 테스트 영상에 표시되는 숫자가 경과 시간(초)이므로, 프리뷰 이미지가 정확한 장면을 보여주는지 비교해 볼 수 있습니다.

플레이어 로딩 중…00:00 / 00:00

데모에 사용한 파일은 다음과 같습니다.

  • sample.mp4: 60초 길이의 테스트 영상
  • sprite.jpg: 2초 간격 30장면, 160×90 칸을 10×3으로 이어 붙인 스프라이트
  • thumbnails.vtt: 30개의 cue에 각 칸의 #xywh 좌표를 적은 VTT

플레이어 준비와 썸네일 트랙 등록

1// src/components/ThumbnailPreviewTrackbar/index.client.tsx (발췌)
2useEffect(() => {
3  let destroyed = false;
4  let player: Player | null = null;
5
6  (async () => {
7    // SSR 단계에서 shaka-player가 평가되지 않도록 브라우저에서만 불러옵니다.
8    const { default: shaka } = await import('shaka-player');
9    if (destroyed || !videoRef.current) return;
10
11    shaka.polyfill.installAll();
12    player = new shaka.Player();
13    playerRef.current = player;
14    await player.attach(videoRef.current);
15    await player.load(src);
16
17    // load()가 끝난 뒤에만 썸네일 트랙을 붙일 수 있습니다.
18    const track = await player.addThumbnailsTrack(thumbnailsVtt, 'text/vtt');
19    trackIdRef.current = track.id;
20
21    const thumbnails = await player.getAllThumbnails(track.id);
22    if (destroyed) return;
23
24    setAllThumbnails(thumbnails ?? []);
25    setDuration(videoRef.current.duration);
26  })();
27
28  return () => {
29    destroyed = true;
30    player?.destroy();
31  };
32}, [src, thumbnailsVtt]);
  • 블로그는 정적 사이트로 미리 렌더링되므로, shaka-player는 useEffect 안에서 동적 import로 불러옵니다. 그래야 서버 렌더링 단계에서 브라우저 전용 코드가 실행되지 않습니다.
  • 앞에서 확인했듯 addThumbnailsTrack은 load()가 끝난 다음에 호출해야 합니다.
  • mimeType은 'text/vtt'로 직접 넘깁니다. 넘기지 않으면 Shaka가 타입을 따로 알아내야 하므로 그만큼 작업이 늘어납니다.
  • 반환된 ImageTrack의 id를 저장해 두고, 이 id로 getAllThumbnails를 호출해 하단 목록을 만듭니다.

호버 위치를 시간으로 바꾸고 썸네일 요청하기

1// src/components/ThumbnailPreviewTrackbar/index.client.tsx (발췌)
2const getTimeFromPointer = (clientX: number) => {
3  const rect = barRef.current!.getBoundingClientRect();
4  const x = Math.max(0, Math.min(rect.width, clientX - rect.left));
5  return { x, time: (x / rect.width) * duration };
6};
7
8const handlePointerMove = async (e: ReactPointerEvent<HTMLDivElement>) => {
9  const player = playerRef.current;
10  if (status !== 'ready' || !player) return;
11
12  const { x, time } = getTimeFromPointer(e.clientX);
13  setHover((prev) => ({ time, x, thumbnail: prev?.thumbnail ?? null }));
14
15  // getThumbnails는 비동기이므로, 늦게 도착한 이전 요청의 결과는 버립니다.
16  const requestId = ++requestIdRef.current;
17  const thumbnail = await player.getThumbnails(trackIdRef.current, time);
18  if (requestId !== requestIdRef.current) return;
19  setHover((prev) => (prev ? { ...prev, thumbnail } : prev));
20};

Shaka UI와 같은 원리입니다. 트랙바 안에서 마우스의 x 좌표가 차지하는 비율에 영상 길이를 곱해 시간을 구합니다. 마우스와 터치를 함께 지원하려고 mousemove 대신 Pointer 이벤트를 사용했습니다.

한 가지 주의할 점은 getThumbnails가 Promise를 반환한다는 것입니다. 마우스를 빠르게 움직이면 요청이 여러 개 겹치는데, 응답 순서가 보장되지 않으면 이전 위치의 썸네일이 나중에 도착해 화면을 덮어쓸 수 있습니다. 그래서 요청마다 번호를 매기고 가장 마지막 요청의 결과만 반영하도록 했습니다.

스프라이트에서 한 칸만 잘라 보여주기

1// src/components/ThumbnailPreviewTrackbar/index.client.tsx (발췌)
2// VTT cue payload에 붙어 있던 미디어 프래그먼트(#xywh=...)를 떼어낸 실제 이미지 URL
3function spriteUrl(thumbnail: Thumbnail) {
4  return thumbnail.uris[0].split('#xywh=')[0];
5}
6
7function ThumbnailCrop({
8  thumbnail,
9  width,
10}: {
11  thumbnail: Thumbnail;
12  width: number;
13}) {
14  // Shaka UI seek_bar.js와 같은 방식: 스프라이트 원본을 scale 배율로 키운 뒤
15  // positionX/Y 만큼 반대 방향으로 밀어 해당 칸만 보이게 합니다.
16  const scale = width / thumbnail.width;
17  return (
18    <div
19      className="relative overflow-hidden rounded-[4px] bg-black"
20      style={{ width, height: Math.floor(thumbnail.height * scale) }}
21    >
22      <img
23        src={spriteUrl(thumbnail)}
24        alt=""
25        draggable={false}
26        className="absolute"
27        style={{
28          left: -scale * thumbnail.positionX,
29          top: -scale * thumbnail.positionY,
30          transform: `scale(${scale})`,
31          transformOrigin: 'left top',
32        }}
33      />
34    </div>
35  );
36}

앞에서 본 Shaka UI의 방식을 그대로 옮겼습니다. 박스 너비 ÷ 썸네일 너비로 배율을 구하고, 이미지를 그만큼 키운 뒤 스프라이트 좌표 × 배율만큼 왼쪽 위로 밀어냅니다. 같은 컴포넌트에 width만 다르게 넘겨서 트랙바 프리뷰(192px)와 하단 목록(80px)에 함께 사용했습니다.

프리뷰 박스 위치 잡기

1// src/components/ThumbnailPreviewTrackbar/index.client.tsx (발췌)
2const barWidth = barRef.current?.clientWidth ?? 0;
3const previewLeft = hover
4  ? Math.min(Math.max(0, hover.x - previewWidth / 2), barWidth - previewWidth)
5  : 0;

Shaka UI와 같은 방식입니다. 마우스 위치를 박스의 중심으로 두되, 트랙바의 왼쪽 끝(0)과 오른쪽 끝(barWidth - previewWidth) 사이로 고정합니다. 그래서 트랙바 끝에 마우스를 올려도 프리뷰가 잘리지 않습니다.

정리

  • 썸네일 프리뷰의 재료는 스프라이트 이미지와 VTT 파일입니다. VTT의 cue 하나가 "이 시간 구간에는 스프라이트의 #xywh=x,y,w,h 영역을 보여줘라"라는 정보를 담고 있습니다.
  • addThumbnailsTrack은 VTT의 cue를 하나씩 썸네일로 바꿔 등록하고, #xywh 좌표를 읽어 둡니다. load() 이후에만 호출할 수 있고 라이브 스트림에서는 쓸 수 없습니다.
  • getThumbnails는 주어진 시간을 포함하는 cue를 찾아 좌표를 돌려주고, getAllThumbnails는 전체 썸네일 목록을 돌려줍니다.
  • 화면에 그릴 때는 마우스 위치를 트랙바 대비 비율로 시간으로 바꾸고, 돌려받은 좌표만큼 스프라이트를 확대·이동해 한 칸만 보이게 합니다. Shaka UI도, 이 글의 ThumbnailPreviewTrackbar도 같은 원리로 동작합니다.