Shaka Player 오픈소스 기여기 - Chromecast 리시버의 ABR 후보군이 좁아지는 문제

들어가며 — Chromecast에서만 화질이 오르지 않는다

Chromecast로 영상을 재생하면 TV에서 재생을 담당하는 것은 Cast Web Receiver 앱입니다. 그런데 네트워크가 충분히 빠른데도 리시버의 화질이 1080p까지 올라가지 않고 480p나 720p에 머무는 문제가 있었습니다.

원인을 따라가 보니 문제는 우리 앱 코드가 아니라 Google의 Cast 리시버 SDK가 내부에서 사용하는 Shaka Player에 있었습니다. 이 글은 그 원인을 분석해 Shaka Player 저장소에 버그 리포트(#10938)를 올리고, 메인테이너의 수정이 다음 릴리스에 반영되기까지의 과정을 정리한 기록입니다.

시점 (UTC)진행
11월 9일 09:37이슈 #10938 등록
11월 10일 08:34메인테이너가 component: HLS 라벨 지정
11월 10일 08:54수정 PR #10941 생성
11월 10일 12:21리뷰 승인 후 머지, 이슈 종료
11월 10일 12:225.4.0 릴리스 PR에 포함

이슈를 올린 지 약 27시간 만에 수정까지 마무리되었습니다.

문제 상황

CAF 리시버와 Shaka Player

Cast Web Receiver는 Google의 CAF(Cast Application Framework) 리시버 SDK로 만듭니다. CAF는 useShakaForHls 옵션을 켜면 HLS를 SDK에 내장된 Shaka Player로 재생합니다. 즉 리시버 앱은 Shaka Player를 직접 생성하지 않고, CAF가 만든 Player 인스턴스를 CAF의 설정을 거쳐 사용합니다.

문제가 발생한 환경은 다음과 같습니다.

항목값
기기Chromecast with Google TV (4K)
CAF 리시버 SDK3.0.0156
SDK에 포함된 Shaka Player4.15.56
콘텐츠HLS, MPEG-TS 세그먼트, AVC + AAC, DRM 없음
Variant270p, 360p, 480p, 720p, 1080p

대역폭은 충분한데 화질이 오르지 않는다

Shaka Player의 대역폭 추정치는 약 30~37Mbps였고, 1080p variant가 선언한 BANDWIDTH는 약 10.7Mbps였습니다. 그런데도 ABR은 720p와 1080p에 머물지 못했습니다. 화질 상한은 아직 한 번도 로드하지 않은 variant에 묶였고, 세션마다 480p가 되기도 하고 720p가 되기도 했습니다.

디버그 오버레이로 플레이어 내부를 확인한 결과는 다음과 같았습니다.

  • 로드한 variant의 video.mimeType은 video/mp2t였습니다. 세그먼트 확장자 .ts에서 결정된 값입니다.
  • 아직 로드하지 않은 variant는 임시값인 video/mp4였습니다.
  • 현재 AdaptationSet에는 270p, 360p, 480p만 들어 있었고, 720p와 1080p는 빠져 있었습니다. 기준 variant와의 차이는 코덱이 아니라 mimeType(video/mp2t와 video/mp4)뿐이었습니다.

원인 분석

CAF는 codecSwitchingStrategy를 RELOAD로 강제한다

Shaka Player의 mediaSource.codecSwitchingStrategy는 서로 다른 코덱 사이를 전환하는 방식을 정합니다. SMOOTH는 SourceBuffer.changeType()으로 끊김 없이 전환하고, RELOAD는 MediaSource를 다시 만들어 전환합니다.

CAF는 원격 플래그 use_change_type_for_codec_switching이 켜져 있지 않으면 Shaka를 설정할 때 이 값을 RELOAD로 덮어씁니다. 따라서 Shaka로 HLS를 재생하는 CAF 리시버는 기본적으로 RELOAD로 동작합니다.

이 설정이 중요한 이유는 ABR 후보군을 만드는 기준이 바뀌기 때문입니다.

1// lib/media/preference_based_criteria.js (5.3.4, 발췌)
2const supportsSmoothCodecTransitions =
3    this.config_.codecSwitchingStrategy ==
4      shaka.config.CodecSwitchingStrategy.SMOOTH &&
5    device.supportsSmoothCodecSwitching(keySystem);
6
7const compareCodecs = !supportsSmoothCodecTransitions;
8const root = Class.chooseVariantWithLargestLadder_(current, compareCodecs);
9this.lastAdaptationSet_ =
10    new shaka.media.AdaptationSet(root, current, compareCodecs);

SMOOTH가 아니면 compareCodecs가 true가 됩니다. 이때 AdaptationSet은 기준(root) variant와 서로 전환 가능한 variant만 후보로 남깁니다.

HLS 지연 로딩과 임시 mimeType

HLS의 마스터 플레이리스트에는 variant별 코덱 정보는 있지만 세그먼트 컨테이너 정보는 없습니다. Shaka Player는 각 variant의 미디어 플레이리스트를 실제로 필요할 때 로드합니다. 그 전까지 비디오 스트림의 mimeType은 추측값으로 채웁니다.

1// lib/hls/hls_parser.js — guessMimeTypeFallback_ (5.3.4, 발췌)
2// If the HLS content is lacking in both MIME type metadata and
3// segment file extensions, we fall back to assuming it's MP4.
4const map =
5    shaka.hls.HlsParser.EXTENSION_MAP_BY_CONTENT_TYPE_.get(contentType);
6return map.get('mp4');

로드하기 전 비디오 스트림에는 guessMimeTypeBeforeLoading_()이 값을 정하지 못해 이 폴백이 적용되고, 모든 비디오 스트림이 video/mp4로 시작합니다. 미디어 플레이리스트를 로드하면 .ts 확장자를 보고 video/mp2t로 바뀝니다. 하지만 다른 variant의 추측값은 고쳐지지 않습니다.

canTransitionBetween_의 mimeType 비교

두 스트림이 전환 가능한지는 AdaptationSet.canTransitionBetween_이 판단합니다.

1// lib/media/adaptation_set.js (5.3.4)
2static canTransitionBetween_(a, b) {
3  if (a.mimeType != b.mimeType) {
4    return false;
5  }
6  // ...코덱 기반 비교
7}

mimeType 문자열이 다르면 코덱을 비교하기도 전에 false를 돌려줍니다. 같은 MPEG-TS, 같은 AVC와 AAC 조합이라도 하나는 로드되어 video/mp2t, 다른 하나는 아직 video/mp4라면 전환할 수 없는 스트림으로 판단합니다.

한 번 로드한 variant가 후보에서 빠진다

이 조건들이 맞물리면 사다리가 위에서부터 줄어듭니다.

  1. 재생 초기에 ABR이 높은 variant(예: 1080p)를 고르고, 미디어 플레이리스트를 로드하면서 video/mp2t가 됩니다.
  2. AdaptationSet의 기준 variant는 아직 로드하지 않은 가장 낮은 variant이고 video/mp4입니다. canTransitionBetween_()이 false를 돌려주므로 1080p는 후보에서 빠집니다.
  3. ABR은 남은 후보 중 가장 좋은 variant(예: 720p)로 내려가 로드하고, 720p도 같은 이유로 빠집니다.
  4. 결국 ABR은 한 번도 로드하지 않은 variant 안에 갇힙니다.

codecSwitchingStrategy를 smooth로 바꾸면 모든 variant가 다시 후보가 되어 같은 기기와 스트림에서 1080p까지 올라갔습니다. 다만 이 우회책은 버전에 따라 막혀 있었습니다. 5.3.x와 main에서는 Chromecast의 supportsSmoothCodecSwitching()이 Linux 기반 기기에서 false를 돌려주므로, 그런 기기에서는 smooth를 설정해도 위 문제를 피할 수 없습니다.

1// lib/device/chromecast.js (5.3.4)
2supportsSmoothCodecSwitching(keySystem) {
3  return super.supportsSmoothCodecSwitching(keySystem) &&
4    this.osType_.value() !== shaka.device.Chromecast.OsType_.LINUX;
5}

버그 리포트 작성

이슈 #10938은 Shaka Player의 Bug Report 템플릿을 따라 작성했습니다. 메인테이너가 바로 재현하고 판단할 수 있도록 다음 내용을 담았습니다.

  • 재현 조건을 최소화했습니다. 리시버가 전달하는 여러 설정 중 문제를 일으키는 것은 codecSwitchingStrategy: 'reload' 하나이고, 나머지 설정은 영향이 없다는 점을 밝혔습니다.
  • 공개 재현 스트림을 제시했습니다. 운영 콘텐츠는 인증이 걸려 공유할 수 없어, 같은 형태(다중 variant HLS, MPEG-TS, AVC와 AAC, DRM 없음)의 Apple 공개 예제 스트림을 함께 적었습니다. 마스터 플레이리스트의 구조도 URI만 줄여 첨부했습니다.
  • 기대 동작과 실제 동작을 구분했습니다. 모든 variant가 같은 컨테이너와 코덱이므로 RELOAD에서도 서로 전환 가능해야 한다는 기대와, 실제로 관찰한 mimeType과 AdaptationSet 구성을 나눠 적었습니다.
  • 원인이 되는 코드 위치를 3곳으로 짚었습니다. hls_parser.js의 추측 mimeType, preference_based_criteria.js의 compareCodecs, adaptation_set.js의 mimeType 비교입니다. 4.15.56, 5.3.4, main에서 같은 코드인 것도 확인했습니다.
  • 수정 방향을 두 가지 제안했습니다. 하나는 아직 로드하지 않은 스트림의 추측 mimeType을 호환으로 취급하는 것이고, 다른 하나는 첫 미디어 플레이리스트를 로드한 뒤 확인된 컨테이너를 다른 variant에 전파하는 것입니다.

메인테이너의 수정

메인테이너가 올린 PR #10941 "fix(ABR): Compare the transmuxed container when building adaptation sets"는 리뷰 승인을 받아 머지되었습니다.

1static canTransitionBetween_(a, b) {
2-  if (a.mimeType != b.mimeType) {
3-    return false;
4-  }
5-  // ...코덱 기반 비교
6+  const MimeUtils = shaka.util.MimeUtils;
7+  // Compare what the SourceBuffer gets, as MediaSourceEngine does: a
8+  // transmuxed stream (e.g. TS) is appended as its converted type, so it
9+  // doesn't need a reset to switch to a stream already in that container.
10+  const getTypeAndCodecBases = (stream) => {
11+    const fullType = MimeUtils.getFullOrConvertedType(
12+        stream.mimeType, stream.codecs, stream.type);
13+    const codecBases = MimeUtils.splitCodecs(MimeUtils.getCodecs(fullType))
14+        .map((codec) => MimeUtils.getCodecBase(codec));
15+    return {basicType: MimeUtils.getBasicType(fullType), codecBases};
16+  };
17+  const infoA = getTypeAndCodecBases(a);
18+  const infoB = getTypeAndCodecBases(b);
19+
20+  return infoA.basicType == infoB.basicType &&
21+      shaka.util.ArrayUtils.hasSameElements(
22+          infoA.codecBases, infoB.codecBases);
23 }

제안과 다른 해결 방식

수정은 이슈에서 제안한 두 방법이 아니었습니다. 메인테이너는 추측값을 특별하게 다루는 대신 비교 기준 자체를 바꿨습니다. 매니페스트에 적힌 mimeType이 아니라, SourceBuffer가 실제로 받는 타입을 비교합니다.

getFullOrConvertedType()은 트랜스먹서가 처리할 수 있는 타입이면 변환된 타입을 돌려줍니다. MPEG-TS를 MP4로 트랜스먹싱하는 환경에서는 video/mp2t 스트림도 video/mp4로 비교되므로, 로드한 TS variant와 아직 video/mp4인 variant가 같은 AdaptationSet에 남습니다.

PR 설명에 따르면 이는 MediaSourceEngine이 리셋 여부를 결정할 때 쓰는 기준과 같습니다. 트랜스먹싱한 TS 스트림과 같은 코덱의 MP4 스트림 사이에는 리셋이 필요 없으니, 후보군에서 제외할 이유도 없다는 판단입니다. 추측값이라는 HLS만의 사정에 기대지 않고, 전환 가능 여부를 실제 재생 경로 기준으로 맞춘 해결입니다.

추가된 테스트

PR에는 다음 테스트가 추가되었습니다.

  • 이슈 상황을 그대로 옮긴 회귀 테스트: 5개 variant 중 로드한 것은 video/mp2t, 나머지는 video/mp4인 매니페스트에서 모든 variant가 후보에 남는지 확인
  • 트랜스먹싱되는 TS와 같은 코덱의 MP4는 전환 가능
  • 코덱이 다르면 전환 불가
  • TS를 트랜스먹싱하지 않는 환경에서는 전환 불가
  • 코덱을 부드럽게 전환할 수 있으면 TS와 MP4가 전환 가능

TS를 트랜스먹싱하지 않는 플랫폼에서는 회귀 테스트를 건너뛰도록 되어 있습니다. 변환 여부에 따라 결과가 달라지는 동작이기 때문입니다.

5.4.0 릴리스 반영

수정 커밋은 머지 직후 릴리스 자동화가 관리하는 5.4.0 릴리스 PR의 Bug Fixes 항목에 포함되었습니다.

ABR: Compare the transmuxed container when building adaptation sets (#10941)

이 글을 쓰는 시점에는 릴리스 PR이 열려 있고 5.4.0은 아직 배포되지 않았습니다. 따라서 체인지로그 항목은 배포 시 바뀔 수 있습니다.

WARNING

Cast 리시버에서 이 수정을 받으려면 CAF SDK에 포함된 Shaka Player 버전이 올라가야 합니다. 문제가 발생한 환경의 SDK는 Shaka Player 4.15.56을 사용했고, 이 수정은 5.4.0 릴리스에 포함될 예정입니다. 리시버 앱에서 Shaka Player 버전을 직접 정하지 않는다면, Cast SDK가 수정된 버전을 포함할 때까지 기다려야 합니다.

마치며

이슈와 PR의 기록을 돌아보면, 리포트가 빠르게 수정으로 이어진 데에는 몇 가지 요소가 있었습니다.

  • 원인을 코드 위치까지 좁혔습니다. "화질이 안 올라간다"는 증상이 아니라, 어떤 값이 어디서 어떻게 비교되어 후보에서 빠지는지를 3개의 코드 위치로 설명했습니다.
  • 재현 조건을 하나의 설정으로 줄였습니다. CAF가 전달하는 여러 설정 중 codecSwitchingStrategy 하나만 문제라는 점을 밝혀, 메인테이너가 Cast 환경 없이도 판단할 수 있었습니다.
  • 공유할 수 없는 콘텐츠 대신 대체 재현 수단을 제시했습니다. 공개 예제 스트림과 마스터 플레이리스트의 구조를 함께 적었습니다.
  • 여러 버전에서 같은 코드인지 확인했습니다. SDK에 포함된 4.15.56뿐 아니라 최신 릴리스와 main에서도 같은 문제가 남아 있다는 점을 보였습니다.

이번 기여는 의도적으로 원인 분석과 제보까지만 진행했습니다. 이슈 템플릿의 "PR을 보낼 계획인가요?"라는 질문에도 "No"라고 답했습니다.

버그 제보자인 저는 문제가 드러난 코드 경로, 즉 Shaka Player의 단편적인 부분만 이해하고 있었습니다. 같은 비교 로직이 다른 포맷이나 기기, 설정 조합에서 어떻게 쓰이는지까지 알지 못한 채 코드를 고치면, 눈앞의 증상만 막는 수정이 될 수 있습니다. 그래서 수정 방향은 제안으로만 남기고, 이슈 해결과 코드 변경은 프로젝트 전체를 이해하는 주 메인테이너에게 맡기는 편이 더 가치 있는 업데이트로 이어질 것이라고 판단했습니다.

결과도 그랬습니다. 메인테이너는 제안한 두 방법 대신 SourceBuffer가 실제로 받는 타입을 비교하는 더 일반적인 방식으로 고쳤습니다. HLS의 추측 mimeType이라는 특정 상황이 아니라, MediaSourceEngine과 같은 기준으로 전환 가능 여부를 판단하도록 바로잡은 수정입니다. 제보자는 원인을 정확히 전달하고, 해결은 프로젝트를 가장 잘 아는 사람이 맡는 역할 분담도 오픈소스에 기여하는 좋은 방법이라는 것을 이번 과정에서 확인했습니다.