라이브 영상의 DVR 윈도우란 무엇인가

들어가며 — 라이브 영상에는 "지금"만 있는 게 아니다

VOD 영상은 재생을 시작하는 순간 전체 타임라인이 이미 정해져 있습니다. 반면 라이브 영상은 그렇지 않습니다. 라이브 영상은 매니페스트를 주기적으로 요청하고 수신하면서 재생되는 특성이 있고, 새로 받은 매니페스트에 따라 "지금 재생할 수 있는 구간"이 계속 갱신됩니다.

그런데 라이브 방송을 보다 보면 조금 뒤로 돌려보는 일이 가능합니다. 이 "돌려볼 수 있는 범위"가 바로 DVR 윈도우입니다. 이 글에서는 DVR 윈도우가 무엇을 기준으로 정해지고, 왜 currentTime이 그 안에 있어야 하며, 벗어났을 때 플레이어가 어떻게 되돌리는지를 Shaka Player 5.1.0 소스코드로 확인해 봅니다.

DVR 윈도우는 매니페스트가 결정한다

DVR 윈도우는 플레이어가 임의로 정하는 값이 아니라, 수신한 매니페스트를 기반으로 설정되는 재생 가능 구간입니다. Shaka Player에서 이 값을 보관하는 곳은 PresentationTimeline이고, 매니페스트 파서가 파싱 결과를 여기에 밀어 넣습니다.

DASH라면 MPD의 timeShiftBufferDepth 속성이 그 출처입니다.

1// dash/dash_parser.js — parseManifest 과정 (발췌)
2let segmentAvailabilityDuration = TXml.parseAttr(
3    mpd, 'timeShiftBufferDepth', TXml.parseDuration);

HLS라면 미디어 플레이리스트에 실제로 나열된 세그먼트들의 길이가 기준이 됩니다.

1// hls/hls_parser.js (발췌)
2// The spec says nothing much about seeking in live content, but Safari's
3// built-in HLS implementation does not allow it.  Therefore we will set
4// the availability window equal to the presentation delay.  The player
5// will be able to buffer ahead three segments, but the seek window will
6// be zero-sized.
7let segmentAvailabilityDuration = this.getLiveDuration_() || 0;
8
9// The app can override that with a longer duration, to allow seeking.
10if (!isNaN(this.config_.availabilityWindowOverride)) {
11  segmentAvailabilityDuration = this.config_.availabilityWindowOverride;
12}
13
14this.presentationTimeline_.setSegmentAvailabilityDuration(
15    segmentAvailabilityDuration);

이렇게 정해진 세그먼트 가용 구간(segment availability window)의 시작과 끝은 다음과 같이 계산됩니다.

1// media/presentation_timeline.js (발췌)
2getSegmentAvailabilityStart() {
3  const end = this.getSegmentAvailabilityEnd();
4  const start = end - this.segmentAvailabilityDuration_;
5  return Math.max(this.userSeekStart_, start);
6}
7
8getSegmentAvailabilityEnd() {
9  // ... 정적 매니페스트 처리 생략 ...
10  // Can be either live or "in-progress recording" (live with known duration)
11  return Math.min(this.getLiveEdge_() + this.availabilityTimeOffset_,
12      this.duration_);
13}

끝(end)은 라이브 엣지이고, 시작(start)은 거기서 가용 시간만큼 뺀 지점입니다. 즉 윈도우의 길이는 유지되지만 두 경계가 함께 앞으로 흘러갑니다.

그리고 실제로 사용자가 탐색할 수 있는 구간, 즉 Seek Range는 여기에 한 겹을 더 씌운 값입니다.

1// media/presentation_timeline.js (발췌)
2getSeekRangeStart() {
3  return this.getSafeSeekRangeStart(/* offset= */ 0);
4}
5
6getSeekRangeEnd() {
7  const useDelay = this.isDynamic();
8  const delay = useDelay ? this.presentationDelay_ : 0;
9  return Math.max(0, this.getSegmentAvailabilityEnd() - delay);
10}

주목할 점은 getSeekRangeEnd()가 가용 구간의 끝에서 presentationDelay_만큼 빼고 있다는 것입니다. 플레이어가 사용자에게 열어주는 재생 구간의 끝은 라이브 엣지 그 자체가 아니라, 한 발 물러선 지점입니다. 이 값은 애플리케이션에서도 그대로 조회할 수 있습니다.

1// player.js — seekRange() (발췌)
2const timeline = this.manifest_.presentationTimeline;
3
4return {
5  'start': timeline.getSeekRangeStart(),
6  'end': timeline.getSeekRangeEnd(),
7};

윈도우는 움직인다 — currentTime이 구간 안에 있어야 하는 이유

여기까지가 핵심 전제입니다. 라이브 영상을 재생하는 동안 이 윈도우는 계속 움직입니다. 그리고 비디오의 currentTime은 언제나 수신한 플레이리스트의 시작과 끝 사이에 위치해야 합니다.

윈도우를 벗어나면 어떤 일이 벌어질까요.

  • 시작보다 뒤로 밀린 경우: 그 지점의 세그먼트는 이미 서버에서 내려간 상태입니다. 받아올 세그먼트가 없으니 채울 버퍼도 없습니다.
  • 끝을 넘어선 경우: 아직 만들어지지 않은 구간입니다. 역시 받아올 것이 없습니다.

두 경우 모두 결과는 같습니다. 재생할 수 있는 버퍼가 없어 stall, buffer starvation 같은 재생이 멈추는 상황이 발생합니다. (Shaka Player의 stall 처리hls.js의 stall 처리를 함께 보면 플레이어가 멈춤을 어떻게 다루는지 비교해 볼 수 있습니다.)

그래서 플레이어 라이브러리들은 이런 상황을 감지하고 currentTime을 라이브 엣지 쪽으로 조정해주는 처리를 내장하고 있습니다. Shaka Player에서 그 역할을 맡은 곳이 Playhead입니다.

Shaka Player가 이탈을 감지하는 곳 —Playhead.onPollWindow_

Shaka Player는 재생이 준비되면 0.25초 주기의 타이머를 돌립니다.

1// media/playhead.js — ready() (발췌)
2ready() {
3  this.checkWindowTimer_.tickEvery(/* seconds= */ 0.25);
4}

이 타이머가 호출하는 함수가 onPollWindow_이고, 이름 그대로 "윈도우를 감시하는" 일을 합니다. JSDoc이 목적을 그대로 말해줍니다 — "Called on a recurring timer to keep the playhead from falling outside the availability window."

1// media/playhead.js — onPollWindow_
2onPollWindow_() {
3  // Don't catch up to the seek range when we are paused or empty.
4  if (this.mediaElement_.readyState == 0 || this.mediaElement_.paused) {
5    return;
6  }
7
8  const currentTime = this.videoWrapper_.getTime();
9  let seekStart = this.timeline_.getSeekRangeStart();
10  const seekEnd = this.timeline_.getSeekRangeEnd();
11
12  if (seekEnd - seekStart < this.minSeekRange_) {
13    seekStart = seekEnd - this.minSeekRange_;
14  }
15
16  if (currentTime < seekStart) {
17    // The seek range has moved past the playhead.  Move ahead to catch up.
18    const targetTime = this.reposition_(currentTime);
19    shaka.log.info('Jumping forward ' + (targetTime - currentTime) +
20                   ' seconds to catch up with the seek range.');
21    this.mediaElement_.currentTime = targetTime;
22  }
23}

currentTime < seekStart, 즉 윈도우가 재생 위치를 앞질러 가버린 상황을 감지해 reposition_이 계산한 지점으로 점프시킵니다. 로그 메시지가 Jumping forward ... seconds to catch up with the seek range.인 것도 이 동작의 성격을 잘 보여줍니다.

중간의 minSeekRange_ 보정도 눈여겨볼 만합니다. 필드 선언부의 주석이 이유를 설명합니다.

1// media/playhead.js — 필드 선언 (발췌)
2/**
3 * The seek range must be at least this number of seconds long. If it is
4 * smaller than this, change it to be this big so we don't repeatedly seek
5 * to keep within a zero-width window.
6 *
7 * This is 3s long, to account for the weaker hardware on platforms like
8 * Chromecast.
9 */
10this.minSeekRange_ = 3.0;

윈도우가 지나치게 좁으면 보정 자체가 무한 반복될 수 있기 때문에, 최소 3초는 확보하고 판단합니다.

어디로 되돌릴 것인가 —reposition_의 분기

감지보다 흥미로운 것은 "그래서 어디로 보낼 것인가" 하는 판단입니다. reposition_이 그 판단을 담당합니다.

1// media/playhead.js — reposition_ (발췌)
2const rebufferingGoal = this.config_.rebufferingGoal;
3const safeSeekOffset = this.config_.safeSeekOffset;
4
5let start = this.timeline_.getSeekRangeStart();
6const end = this.timeline_.getSeekRangeEnd();
7const duration = this.timeline_.getDuration();
8
9if (end - start < this.minSeekRange_) {
10  start = end - this.minSeekRange_;
11}
12
13// With live content, the beginning of the availability window is moving
14// forward.  This means we cannot seek to it since we will "fall" outside
15// the window while we buffer.  So we define a "safe" region that is far
16// enough away.  For VOD, |safe == start|.
17const safe = this.timeline_.getSafeSeekRangeStart(rebufferingGoal);
18
19// These are the times to seek to rather than the exact destinations.  When
20// we seek, we will get another event (after a slight delay) and these steps
21// will run again.  So if we seeked directly to |start|, |start| would move
22// on the next call and we would loop forever.
23const seekStart = this.timeline_.getSafeSeekRangeStart(safeSeekOffset);
24const seekSafe = this.timeline_.getSafeSeekRangeStart(
25    rebufferingGoal + safeSeekOffset);

주석에 이 글의 핵심이 그대로 적혀 있습니다. 라이브에서는 윈도우의 시작 지점으로 정확히 이동하면 안 됩니다. 버퍼링하는 사이에 윈도우가 또 전진해서 다시 바깥으로 "떨어지기" 때문입니다. 그래서 Shaka는 rebufferingGoalsafeSeekOffset만큼 여유를 둔 안전 지점(safe region)을 별도로 계산합니다.

이어지는 분기가 실제 목적지를 고릅니다.

1// media/playhead.js — reposition_ (발췌)
2if (currentTime >= duration) {
3  return this.clampSeekToDuration_(currentTime);
4}
5
6if (currentTime > end) {
7  // We remove the safeSeekEndOffset of the seek end to avoid the player
8  // to be block at the edge in a live stream
9  return end - this.config_.safeSeekEndOffset;
10}
11
12if (currentTime < start) {
13  if (this.timeline_.isLive() &&
14      this.config_.returnToEndOfLiveWindowWhenOutside) {
15    return end - this.config_.safeSeekEndOffset;
16  }
17  if (isBuffered(seekStart)) {
18    return seekStart;
19  } else {
20    return seekSafe;
21  }
22}
23
24if (currentTime >= safe || isBuffered(currentTime)) {
25  return currentTime;
26} else {
27  return seekSafe;
28}

정리하면 이렇습니다.

  • 끝을 넘어선 경우(currentTime > end)end - safeSeekEndOffset으로 되돌립니다. 주석이 밝히듯 "엣지에 붙어 멈춰 있는 것을 피하기 위해" 끝에서 한 번 더 빼줍니다.
  • 시작보다 앞선 경우(currentTime < start) — 해당 지점이 이미 버퍼에 있으면 seekStart로, 아니라면 더 여유 있는 seekSafe로 보냅니다. returnToEndOfLiveWindowWhenOutside 옵션을 켜두면 아예 윈도우의 끝 쪽으로 되돌립니다.
  • 윈도우 안이지만 안전 구간 밖이고 버퍼도 없는 경우 — 역시 seekSafe로 밀어줍니다.

관련 설정의 기본값은 다음과 같습니다.

1// util/player_configuration.js (발췌)
2safeSeekOffset: 5,
3safeSeekEndOffset: 0,
4returnToEndOfLiveWindowWhenOutside: false,
INFO

safeSeekOffset의 공식 문서 설명은 이 트레이드오프를 잘 요약합니다. "This gives the player more time to buffer before falling outside again, but increases the forward jump in the stream skipping more content."여유를 크게 줄수록 다시 이탈할 위험은 줄지만, 그만큼 더 많은 구간을 건너뛰게 됩니다.

일시정지라는 사각지대

이제 앞에서 지나쳤던 onPollWindow_의 첫 줄을 다시 봅시다.

1// media/playhead.js — onPollWindow_ (발췌)
2// Don't catch up to the seek range when we are paused or empty.
3if (this.mediaElement_.readyState == 0 || this.mediaElement_.paused) {
4  return;
5}

일시정지 상태에서는 윈도우 감시가 동작하지 않습니다. 그럴 만한 이유가 있습니다. 사용자가 의도적으로 멈춰둔 화면을 플레이어가 마음대로 앞으로 끌고 가버리면 그것대로 이상한 경험이기 때문입니다.

문제는 일시정지하는 동안에도 DVR 윈도우는 계속 전진한다는 데 있습니다. currentTime은 멈춘 자리에 그대로 있고 윈도우만 흘러가므로, 정지 시간이 길어지면 재개 시점의 currentTime은 이미 윈도우 바깥에 놓이게 됩니다. 재생을 재개하면 그때부터 폴링이 다시 돌면서 보정이 이루어지지만, 그 보정이 일어나기 전까지는 받아올 세그먼트가 없는 지점에 머무는 구간이 생깁니다.

실전 대응 — 재개 시점의currentTime 재조정

그래서 간단하면서도 효과적인 대응은, 일시정지에서 재생으로 재개되는 시점에 애플리케이션이 직접 currentTime을 라이브 엣지 쪽으로 재조정해주는 것입니다. 플레이어의 보정을 기다리는 대신, 재개하는 그 순간에 재생 가능한 위치로 옮겨두는 방식입니다.

1// 재생 재개 시점에 라이브 엣지 근처로 재조정하는 예시
2const LIVE_EDGE_MARGIN = 3; // 엣지 끝자락은 피한다
3
4video.addEventListener('play', () => {
5  if (!player.isLive()) return;
6
7  const { start, end } = player.seekRange();
8  const target = Math.max(start, end - LIVE_EDGE_MARGIN);
9
10  if (video.currentTime < start || video.currentTime > target) {
11    video.currentTime = target;
12  }
13});

여기서 중요한 것은 대개의 경우 라이브 엣지의 끝자락으로 붙이지는 않는다는 점입니다. 어느 정도 뒤로 물러선 지점을 목표로 잡습니다. 끝자락에 바짝 붙으면 남은 버퍼가 거의 없어 네트워크가 조금만 흔들려도 다시 재생이 끊기기 때문입니다.

TIP

이 "한 발 물러서기"는 애플리케이션만의 요령이 아니라 플레이어 내부의 설계이기도 합니다. 앞서 본 getSeekRangeEnd()presentationDelay_를 빼는 것, reposition_end - safeSeekEndOffset을 돌려주는 것 모두 같은 이유에서 나온 처리입니다. Shaka Player의 라이브 동기화 관련 설정은 Shaka Player로 보는 라이브 스트리밍 개론에서 함께 다뤘습니다.

정리

  • 라이브 영상은 매니페스트를 주기적으로 받아 재생하며, DVR 윈도우는 수신한 매니페스트를 기반으로 설정되는 재생 가능 구간입니다. DASH의 timeShiftBufferDepth, HLS의 미디어 플레이리스트 길이가 그 출처입니다.
  • 이 윈도우는 재생하는 동안 계속 움직입니다. currentTime은 언제나 그 시작과 끝 사이에 있어야 하고, 벗어나면 받아올 세그먼트가 없어 stall / buffer starvation으로 이어집니다.
  • Shaka Player는 Playhead.onPollWindow_0.25초마다 돌려 이탈을 감지하고, reposition_이 계산한 안전 지점으로 currentTime을 점프시킵니다. 윈도우의 경계로 정확히 이동하지 않는 이유는 버퍼링하는 사이 다시 이탈하기 때문입니다.
  • 다만 일시정지 중에는 이 감시가 동작하지 않습니다. 그동안 윈도우만 전진하므로, 재개 시점에 애플리케이션이 직접 currentTime을 라이브 엣지 근처로 재조정해주는 대응이 유효합니다. 끝자락이 아니라 한 발 물러선 지점을 잡아야 재생이 끊기지 않습니다.