Shaka Player 파헤치기 - DASH 매니페스트 파서
들어가며 — parser_.start() 안으로
지난 PreloadManager 편에서 우리는 parseManifestInner_가 ManifestParser.getFactory(uri, mimeType)로 알맞은 파서를 고른 뒤 parser_.start()를 호출한다는 데까지 따라갔습니다.
1// preload_manager.js — parseManifestInner_ (발췌)
2this.parserFactory_ = shaka.media.ManifestParser.getFactory(
3 this.assetUri_, this.mimeType_); // DASH면 DASH 파서, HLS면 HLS 파서
4this.parser_ = this.parserFactory_();
5this.manifest_ = await this.parser_.start(
6 this.assetUri_, this.manifestPlayerInterface_);
그렇다면 이 parser_.start()는 안에서 무슨 일을 할까요? MPD든 M3U8이든, 결국 Shaka Player가 다룰 수 있는 단 하나의 내부 모델(variants·textStreams·presentationTimeline)로 바꿔 내야 합니다. 이 글에서는 그중 DASH 파서, 즉 lib/dash/dash_parser.js로 한 단계 더 내려가, XML 한 덩이가 어떻게 재생 가능한 매니페스트 모델이 되는지를 추적합니다. HLS 파서는 다음 편에서 다루겠습니다.
start() — 얇은 진입점
DashParser의 start()는 의외로 짧습니다.
1// dash_parser.js — start()
2async start(uri, playerInterface) {
3 this.lowLatencyMode_ = playerInterface.isLowLatencyMode();
4 this.manifestUris_ = [uri];
5 this.playerInterface_ = playerInterface;
6
7 const updateDelay = await this.requestManifest_(); // 실제 작업은 전부 여기
8
9 if (this.playerInterface_) {
10 this.setUpdateTimer_(updateDelay); // 라이브라면 갱신 타이머를 건다
11 }
12
13 goog.asserts.assert(this.manifest_, 'Manifest should be non-null!');
14 return this.manifest_;
15}
start()가 하는 일은 세 가지뿐입니다. 요청하고(requestManifest_), 갱신 타이머를 걸고(setUpdateTimer_), 만들어진 manifest_를 돌려준다. 실제 변환 로직은 한 줄도 없습니다.
PreloadManager 편에서 본 "얇은 진입점이 내부 메서드에 위임한다"는 패턴이 여기서도 반복됩니다. start()의 await requestManifest_() 한 줄 뒤로 3,000줄이 넘는 파싱 코드가 숨어 있는 셈입니다. 흥미롭게도 setUpdateTimer_가 start() 안에 있다는 점은, 라이브 스트림의 주기적 갱신이 최초 파싱과 같은 진입점에서 시작된다는 사실을 알려줍니다. 이 이야기는 마지막에 다시 하겠습니다.
requestManifest_ — MPD를 받아오기
requestManifest_는 네트워크에서 MPD를 받아 파싱으로 넘기는 다리입니다.
1// dash_parser.js — requestManifest_ (발췌)
2async requestManifest_() {
3 const requestType = shaka.net.NetworkingEngine.RequestType.MANIFEST;
4 let rootElement = 'MPD';
5 let manifestUris = this.manifestUris_;
6
7 // 부분 갱신용 Patch 위치가 있으면 그쪽을, 콘텐츠 스티어링이 있으면 지정 location을
8 const patchLocationUris = this.getPatchLocationUris_();
9 if (patchLocationUris.length) {
10 manifestUris = patchLocationUris;
11 rootElement = 'Patch';
12 } else if (this.manifestUris_.length > 1 && this.contentSteeringManager_) {
13 // ...content steering으로 manifestUris 교체
14 }
15
16 const request = shaka.net.NetworkingEngine.makeRequest(
17 manifestUris, this.config_.retryParameters);
18 const startTime = Date.now();
19 const response = await this.makeNetworkRequest_(request, requestType, {type});
20
21 // 리다이렉트되었다면 최종 uri를 후보 목록 맨 앞에 끼워 둔다
22 if (response.uri && response.uri != response.originalUri &&
23 !this.manifestUris_.includes(response.uri)) {
24 this.manifestUris_.unshift(response.uri);
25 }
26
27 await this.parseManifest(response.data, response.uri, rootElement);
28
29 // 갱신에 걸린 시간을 지수가중이동평균에 기록 (느린 기기 보정용)
30 const updateDuration = (Date.now() - startTime) / 1000.0;
31 this.averageUpdateDuration_.sample(1, updateDuration);
32 return updateDuration;
33}
여기서 눈여겨볼 점이 둘 있습니다.
첫째, 요청 대상이 고정이 아닙니다. 평소에는 원본 MPD URI를 쓰지만, 부분 갱신용 PatchLocation이 있으면 Patch 문서를, 콘텐츠 스티어링이 켜져 있으면 스티어링 서버가 지정한 위치를 받습니다. DASH의 다양한 배포 시나리오가 이 분기 하나에 응축돼 있습니다.
둘째, 갱신에 걸린 시간을 averageUpdateDuration_(EWMA, 지수가중이동평균)에 기록합니다. 임베디드 기기처럼 파싱이 느린 환경에서 다음 갱신 주기를 보정하기 위한 값으로, 뒤에서 다시 등장합니다.
parseManifest — XML이 트리가 되기까지
받아온 바이트는 이제 XML로 파싱됩니다.
1// dash_parser.js — parseManifest / processParsedMpd (발췌)
2parseManifest(data, finalManifestUri, rootElement) {
3 const mpd = shaka.util.TXml.parseXml(data, rootElement); // 바이트 → XML 트리
4 return this.processParsedMpd(mpd, finalManifestUri, rootElement);
5}
6
7async processParsedMpd(mpd, finalManifestUri, rootElement) {
8 // 사용자가 등록한 전처리 훅이 있으면 XML 트리를 먼저 손본다
9 const manifestPreprocessorTXml = this.config_.dash.manifestPreprocessorTXml;
10 if (manifestPreprocessorTXml != defaultManifestPreprocessorTXml) {
11 manifestPreprocessorTXml(mpd);
12 }
13
14 if (rootElement === 'Patch') {
15 return this.processPatchManifest_(mpd); // 부분 갱신 경로
16 }
17
18 if (!shaka.dash.MpdUtils.hasXlinks(mpd)) {
19 return this.processManifest_(mpd, finalManifestUri); // 일반 경로
20 }
21
22 // xlink가 있으면 외부 조각들을 먼저 당겨와 합친 뒤 처리
23 const xlinkOperation = shaka.dash.MpdUtils.processXlinks(/* ... */);
24 this.operationManager_.manage(xlinkOperation);
25 const finalMpd = await xlinkOperation.promise;
26 return this.processManifest_(finalMpd, finalManifestUri);
27}
processParsedMpd는 본격 처리 전에 세 갈래를 정리합니다. 전처리 훅(manifestPreprocessorTXml)으로 비표준 MPD를 교정할 기회를 주고, Patch 문서라면 부분 갱신 경로로 보내고, xlink(외부 조각 참조)가 있으면 그 조각들을 먼저 내려받아 트리에 이어 붙입니다. 이 모든 갈래가 결국 processManifest_라는 하나의 처리기로 합류합니다.
processManifest_ — 모델을 조립하는 본체
processManifest_는 이 파일에서 가장 긴 메서드이자, XML 트리가 비로소 manifest_ 모델이 되는 곳입니다. 큰 줄기만 추리면 이렇습니다.
1// dash_parser.js — processManifest_ (핵심 골격)
2async processManifest_(mpd, finalManifestUri) {
3 // 1) Location / BaseURL / ContentSteering 같은 배포 메타데이터 수집
4 // 2) MPD 속성으로 재생 타임라인의 골격을 잡는다
5 const mpdType = mpd.attributes['type'] || 'static'; // static(VOD) | dynamic(LIVE)
6 this.updatePeriod_ = TXml.parseAttr(mpd, 'minimumUpdatePeriod', ...);
7 // availabilityStartTime, timeShiftBufferDepth, maxSegmentDuration ...
8 let presentationTimeline = /* PresentationTimeline 구성 */;
9
10 // 3) 계층 파싱의 루트가 될 context(상속 프레임의 시작점)
11 const context = {
12 dynamic: mpdType != 'static',
13 presentationTimeline,
14 period: null, adaptationSet: null, representation: null,
15 // ...
16 };
17
18 // 4) Period들을 파싱한다 (여기서 트리를 타고 내려간다)
19 const periodsAndDuration = this.parsePeriods_(context, getBaseUris, mpd, false);
20 const periods = periodsAndDuration.periods;
21
22 // 5) 최초 파싱일 때만: Period들을 평탄화해 모델을 새로 조립
23 if (!this.manifest_) {
24 await this.periodCombiner_.combinePeriods(periods, context.dynamic);
25
26 this.manifest_ = {
27 presentationTimeline,
28 variants: this.periodCombiner_.getVariants(),
29 textStreams: this.periodCombiner_.getTextStreams(),
30 imageStreams: this.periodCombiner_.getImageStreams(),
31 type: shaka.media.ManifestParser.DASH,
32 nextUrl: this.parseMpdChaining_(mpd),
33 periodCount: periods.length,
34 isLowLatency: this.isLowLatency_,
35 // ...
36 };
37
38 if (presentationTimeline.usingPresentationStartTime()) {
39 // UTCTiming으로 서버-클라이언트 시계를 맞춘다 (라이브 동기화의 토대)
40 const offset = await this.parseUtcTiming_(getBaseUris, timingElements);
41 presentationTimeline.setClockOffset(offset);
42 }
43 presentationTimeline.lockStartTime();
44 } else {
45 // 6) 갱신일 때는 모델을 새로 만들지 않고 일부만 갈아끼운다
46 await this.postPeriodProcessing_(periods, /* isPatchUpdate= */ false);
47 }
48
49 this.playerInterface_.makeTextStreamsForClosedCaptions(this.manifest_);
50}
흐름을 정리하면 이렇습니다.
- 배포 메타데이터 수집 —
Location, BaseURL, ContentSteering을 읽어 어디서 세그먼트를 받을지 결정합니다.
- 타임라인 골격 구성 —
type, minimumUpdatePeriod, availabilityStartTime, timeShiftBufferDepth 같은 MPD 속성으로 PresentationTimeline을 세웁니다. 재생 가능한 구간과 라이브 윈도우가 여기서 정해집니다.
- 계층 파싱 —
parsePeriods_로 Period 트리를 타고 내려가며 스트림을 만듭니다.
- 모델 조립 —
periodCombiner_가 평탄화한 결과로 manifest_ 객체 리터럴을 만듭니다.
특히 manifest_ 객체 리터럴을 보면, PreloadManager 편에서 getManifest()로 받아 갔던 그 모델의 정체가 드러납니다. variants, textStreams, presentationTimeline, type, nextUrl... DASH라는 포맷 고유의 모든 것이 여기서 포맷 중립적인 공통 모델로 번역됩니다.
INFO
mpdType 하나가 이후 거의 모든 분기를 가릅니다. static(VOD)이냐 dynamic(LIVE)이냐에 따라 PresentationTimeline 구성, 전체 길이(duration) 계산 방식, periodCombiner_를 해제할지 여부, 그리고 start()에서 갱신 타이머를 걸지 말지가 전부 달라집니다. DASH 파서를 읽을 때 context.dynamic을 계속 의식하면 코드가 훨씬 잘 읽힙니다.
계층 파싱과 상속 프레임
DASH의 MPD는 MPD > Period > AdaptationSet > Representation이라는 계층 구조를 가집니다. 파서는 이 구조를 그대로 트리로 내려가며 파싱합니다.
1// dash_parser.js — 계층을 타고 내려가는 호출 사슬
2parsePeriods_ // 모든 Period를 순회
3 └─ parsePeriod_ // EventStream·SupplementalProperty 처리 후
4 └─ parseAdaptationSet_ // 각 AdaptationSet을 map으로 파싱
5 └─ parseRepresentation_ // 최종적으로 Stream 하나를 만든다
1// dash_parser.js — parsePeriod_ (발췌)
2const adaptationSets =
3 TXml.findChildren(periodInfo.node, 'AdaptationSet')
4 .map((node, position) =>
5 this.parseAdaptationSet_(context, position, node))
6 .filter(Functional.isNotNull);
이 트리 파싱에서 가장 우아한 부분이 상속 프레임(InheritanceFrame)입니다. DASH 명세는 상위 요소에 적은 속성을 하위 요소가 물려받도록 허용합니다. 예컨대 mimeType을 AdaptationSet에 한 번만 적으면 그 아래 모든 Representation이 그것을 공유합니다. createFrame_가 바로 이 규칙을 구현합니다.
1// dash_parser.js — createFrame_ (발췌)
2createFrame_(elem, parent, getBaseUris) {
3 parent = parent || /* 루트 기본값 */ ({ contentType: '', mimeType: '', codecs: '', /* ... */ });
4 getBaseUris = getBaseUris || parent.getBaseUris;
5
6 // 자식에 값이 있으면 자식 것을, 없으면 부모에게서 물려받는다
7 const contentType = elem.attributes['contentType'] || parent.contentType;
8 const mimeType = elem.attributes['mimeType'] || parent.mimeType;
9 const allCodecs = [elem.attributes['codecs'] || parent.codecs];
10 // BaseURL, SegmentTemplate, availabilityTimeOffset ... 모두 같은 방식으로 캐스케이드
11 // ...
12}
자식 속성 || 부모 속성. 이 단순한 한 줄짜리 패턴이 Period → AdaptationSet → Representation을 따라 반복되며, 상위에서 한 번 선언한 값이 아래로 흘러내리는 DASH의 상속 캐스케이드를 그대로 코드로 옮깁니다. 각 계층을 파싱할 때 createFrame_(elem, parentFrame, ...)로 새 프레임을 만들어 부모를 넘기면, 명세의 상속 규칙이 자연스럽게 적용됩니다.
TIP
dynamic(라이브) 매니페스트에서는 parsePeriod_가 모든 Representation의 ID 유일성을 검사하고, 중복이 있으면 DASH_DUPLICATE_REPRESENTATION_ID오류를 던집니다. 라이브에서는 갱신 사이에 Representation을 ID로 추적해 같은 스트림을 이어붙여야 하기 때문에, ID가 겹치면 추적이 깨집니다.
PeriodCombiner — 여러 Period를 하나로 평탄화
계층 파싱이 끝나면 우리는 Period의 배열을 손에 쥡니다. 그런데 사용자는 "1번 Period의 720p, 2번 Period의 720p"를 따로 인식하고 싶지 않습니다. 광고가 중간에 삽입되어 Period가 여러 개로 쪼개져 있더라도, 끊김 없이 이어지는 하나의 720p 트랙으로 재생되길 바라죠.
그 일을 하는 것이 PeriodCombiner입니다.
1// dash_parser.js — processManifest_ (발췌)
2await this.periodCombiner_.combinePeriods(periods, context.dynamic);
3
4this.manifest_ = {
5 // ...
6 variants: this.periodCombiner_.getVariants(), // 평탄화된 variant
7 textStreams: this.periodCombiner_.getTextStreams(),
8 imageStreams: this.periodCombiner_.getImageStreams(),
9 // ...
10};
combinePeriods는 여러 Period에 흩어진 스트림들을 가로질러, 호환되는 것끼리 이어 붙여(flatten) 연속 재생 가능한 variant로 합칩니다. 다중 Period 광고 삽입(multi-period) 같은 시나리오에서 Period 경계를 넘어 매끄럽게 재생하기 위한 핵심 장치입니다.
여기에도 VOD/LIVE의 갈림이 있습니다. 평탄화가 끝난 뒤, VOD라면 더 이상 Period가 추가될 일이 없으므로 periodCombiner_를 곧장 release()합니다. 반면 라이브는 갱신 때마다 새 Period가 붙을 수 있으니 살려 둡니다.
1// dash_parser.js — processManifest_ (발췌)
2if (this.periodCombiner_ && !this.manifest_.presentationTimeline.isLive()) {
3 this.periodCombiner_.release(); // VOD는 더 합칠 Period가 없다
4}
라이브 MPD 갱신 루프
마지막 조각은 라이브입니다. 라이브 MPD는 시간이 지나면 새 세그먼트가 추가되고 오래된 세그먼트는 만료됩니다. 그래서 파서는 주기적으로 MPD를 다시 받아 와야 합니다. 그 주기를 잡는 것이 setUpdateTimer_입니다.
1// dash_parser.js — 갱신 타이머
2this.updateTimer_ = new shaka.util.Timer(() => {
3 if (this.mediaElement_ && !this.config_.continueLoadingWhenPaused) {
4 this.eventManager_.unlisten(this.mediaElement_, 'timeupdate');
5 if (this.mediaElement_.paused) {
6 // 일시정지 중이면, 재생이 재개될 때까지 갱신을 미룬다
7 this.eventManager_.listenOnce(
8 this.mediaElement_, 'timeupdate', () => this.onUpdate_());
9 return;
10 }
11 }
12 this.onUpdate_();
13});
타이머가 발화하면 onUpdate_가 돌고, 이는 다시 requestManifest_를 호출합니다. 최초 파싱과 똑같은 경로를 재사용하는 것이죠.
1// dash_parser.js — onUpdate_ (발췌)
2async onUpdate_() {
3 let updateDelay = 0;
4 try {
5 updateDelay = await this.requestManifest_(); // 같은 경로 재사용
6 } catch (error) {
7 // 실패해도 치명적이 아니면 심각도를 낮춰 재시도
8 }
9 if (!this.playerInterface_) {
10 return;
11 }
12 this.playerInterface_.onManifestUpdated(); // 플레이어에 갱신을 통지
13 this.setUpdateTimer_(updateDelay); // 다음 갱신을 다시 예약
14}
여기서 설계상 중요한 두 가지를 짚고 넘어가겠습니다.
1. 갱신은 모델을 새로 만들지 않는다. 앞서 processManifest_에서 봤듯, this.manifest_가 이미 있으면(=갱신이면) 모델을 통째로 다시 만들지 않고 postPeriodProcessing_로 variants·textStreams만 갈아끼우고 filter로 재검증합니다. 그리고 갱신 처리 초입에서는 기존 스트림들의 만료된 세그먼트를 evict해 정리합니다. PreloadManager 편에서 "preload와 load가 플래그 하나로 같은 경로를 공유"하던 것처럼, 최초 파싱과 라이브 갱신도 같은 requestManifest_/processManifest_를 공유하되 this.manifest_의 존재 여부로 갈립니다.
1// dash_parser.js — postPeriodProcessing_ (발췌)
2async postPeriodProcessing_(periods, isPatchUpdate) {
3 await this.periodCombiner_.combinePeriods(periods, true, isPatchUpdate);
4 this.manifest_.variants = this.periodCombiner_.getVariants();
5 // textStreams, imageStreams 갱신 ...
6 this.playerInterface_.filter(this.manifest_); // 제약·DRM 기준 재필터링
7}
2. 반복 타이머가 아니다. setUpdateTimer_는 repeating 타이머가 아니라, 매 갱신이 끝날 때마다 tickAfter로 다음 한 번만 예약합니다. 갱신 자체가 비동기(네트워크 + 파싱)라서, 이전 갱신이 끝나기도 전에 다음 갱신이 겹쳐 발화하는 일을 막기 위함입니다. 다음 주기는 minimumUpdatePeriod와 앞서 기록해 둔 averageUpdateDuration_ 추정치 중 더 큰 값으로 잡혀, 느린 기기에서 갱신이 밀리지 않도록 보정됩니다.
1// dash_parser.js — setUpdateTimer_ (발췌)
2const finalDelay = Math.max(
3 updateTime - offset,
4 this.averageUpdateDuration_.getEstimate());
5// repeating이 아니라 매번 async 완료 후 재예약
6this.updateTimer_.tickAfter(/* seconds= */ finalDelay);
정리
DASH 파서는 XML 한 덩이를 계층 상속과 Period 평탄화라는 두 변환을 거쳐 포맷 중립적인 manifest_ 모델로 바꿔 냅니다.
| 단계 | 메서드 | 하는 일 |
|---|
| 진입 | start | requestManifest_ 호출 → (라이브면) 갱신 타이머 → manifest_ 반환 |
| 요청 | requestManifest_ | MPD/Patch/스티어링 위치에서 MPD 수신, 갱신 시간 기록 |
| 파싱 | parseManifest → processParsedMpd | XML 트리화, 전처리·Patch·xlink 분기 |
| 조립 | processManifest_ | 타임라인 구성 → 계층 파싱 → 모델 리터럴 조립 |
| 계층 | parsePeriod_ → parseAdaptationSet_ → parseRepresentation_ | 트리를 내려가며 Stream 생성, createFrame_로 속성 상속 |
| 평탄화 | periodCombiner_.combinePeriods | 여러 Period를 연속 variant로 합침 |
| 갱신 | onUpdate_ | 라이브 MPD를 같은 경로로 재처리, 일부만 갱신 |
핵심 설계를 다시 모으면 다음과 같습니다.
- 얇은 진입점, 내부 위임:
start()는 세 줄짜리이고, 변환의 무게는 requestManifest_ → parseManifest → processManifest_가 짊어집니다.
- 계층 상속:
createFrame_의 자식 || 부모 패턴이 MPD → Period → AdaptationSet → Representation의 속성 캐스케이드를 그대로 구현합니다.
- 평탄화:
PeriodCombiner가 다중 Period를 Period 경계 너머로 이어붙여 하나의 연속 variant로 만듭니다.
- static vs dynamic:
type 속성 하나가 타임라인·duration·combiner 해제·갱신 타이머까지 모든 갈림을 결정하며, 라이브 갱신은 최초 파싱과 같은 경로를 재사용해 일부만 갈아끼웁니다.
한 줄로 요약하면 다음과 같습니다.
"DASH 파서는 XML 트리를 상속 규칙으로 풀어 스트림을 만들고, 여러 Period를 평탄화해 연속 variant로 합친 뒤, 라이브라면 같은 길을 주기적으로 되밟는다."
다음 편에서는 또 다른 매니페스트 포맷인 HLS 파서로 넘어가겠습니다. 단일 XML로 끝나는 DASH와 달리, HLS는 마스터 M3U8과 미디어 플레이리스트라는 2단 구조를 가집니다. PreloadManager 편에서 "HLS는 DRM 정보가 미디어 플레이리스트 안에 있어 세그먼트 인덱스를 먼저 만들어야 한다"고 짚었던 그 특수성이, HLS 파서를 따라가다 보면 자연스럽게 드러날 것입니다.