[iOS] DASH 에 대하여

이상진·2025년 12월 17일

HLS, DASH 학습 기록

목록 보기
4/4
post-thumbnail

개요

지난 포스트까진 HLS 에 대해 알아보았고, 이번에는 MPEG-DASH 에 대해 학습하고자 한다. DASH 는 HLS 와 같이 비디오 스트리밍 프로토콜의 양대산맥(?) 으로 대표할 수 있는 프로토콜인데 HLS 학습과 동일하게 DASH 가 무엇인지, 사용하는 파일의 규격은 어떻게 되는지, AVPlayer 를 이용해서 플레이어 만들어보기 등 학습 과정을 기록해보겠다.


DASH 란?

MPEG-DASH
Dynamic Adaptive Streaming over HTTP (DASH), also known as MPEG-DASH, is an adaptive bitrate streaming technique that enables high quality streaming of media content over the Internet delivered from conventional HTTP web servers.

DASH는 HTTP 기반의 적응형 비트레이트 스트리밍 프로토콜로 HLS와 함께 가장 널리 사용되는 스트리밍 프로토콜 중 하나이다. 아무래도 DASH 는 국제 표준 프로토콜이고 HLS은 Apple 에서 직접 만든 프로토콜이다보니 AVPlayer 와 호환되지 않는다. 그래서 iOS 기기에서 DASH 프로토콜을 따르는 비디오를 재생하기 위해선 다음과 같은 방법을 활용할 수 있다.

1. WebKit 을 이용한 방법

가장 간단한 접근 방식은 WKWebView를 활용하여 웹 기반 DASH 플레이어를 임베드하는 것이다. dash.js 같은 JavaScript 라이브러리를 사용하면 DASH 스트림을 브라우저에서 재생할 수 있다. 장점으로는 구현이 매우 간단하고 빠른 것이지만 단점으로는 AVPlayerPicture-in-Picture, AirPlay 같은 네이티브 기능 통합이 제한적이라는 점이다. 네이티브 수준의 성능과 최적화를 기대하기 어렵다.

import WebKit

class DashPlayerViewController: UIViewController {
    var webView: WKWebView!
    
    override func loadView() {
        let config = WKWebViewConfiguration()
        config.allowsInlineMediaPlayback = true
        config.mediaTypesRequiringUserActionForPlayback = []
        
        webView = WKWebView(frame: .zero, configuration: config)
        view = webView
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        loadDashPlayer()
    }
    
    func loadDashPlayer() {
        let html = """
        <!DOCTYPE html>
        <html>
        <head>
            <meta name="viewport" content="width=device-width, initial-scale=1">
            <script src="https://cdn.dashjs.org/latest/dash.all.min.js"></script>
        </head>
        <body style="margin:0;padding:0;">
            <video id="videoPlayer" controls style="width:100%;height:100vh;"></video>
            <script>
                var url = "\(dashManifestURL)";
                var player = dashjs.MediaPlayer().create();
                player.initialize(document.querySelector("#videoPlayer"), url, true);
            </script>
        </body>
        </html>
        """
        
        webView.loadHTMLString(html, baseURL: nil)
    }
}

2. AVAssetResourceLoader를 이용한 가상 HLS Playlist 제공

AVAssetResourceLoader를 활용하여 MPD 파일을 파싱한 후, AVPlayer가 이해할 수 있는 가상의 HLS playlist로 변환하는 방법이다. 실제로는 DASH 세그먼트를 다운로드하지만, AVPlayer 입장에서는 HLS를 재생하는 것처럼 동작한다. 핵심 아이디어는 커스텀 URL scheme(예: dash://)을 사용하여 AVPlayer의 리소스 요청을 가로채고, DASH 세그먼트를 매핑해서 제공하는 것이다. 이렇게 되면 AVPlayer의 모든 네이티브 기능을 그대로 사용 가능 (PiP, AirPlay, 자막 등)하고 최적화된 버퍼링, 디코딩 파이프라인 활용할 수 있다는 점이 있다. 다만 단점으로는 MPD의 복잡한 구조(SegmentTemplate, SegmentTimeline 등)를 모두 파싱해야 한다는 점이 있다.

3. 커스텀 플레이어 직접 개발

AVPlayer를 전혀 사용하지 않고, VideoToolbox로 디코딩하고 Metal/AVSampleBufferDisplayLayer로 렌더링하는 완전한 커스텀 플레이어를 만드는 방법이다. 이렇게 되면 플레이어의 모든 측면을 완벽하게 제어 가능하고 DRM, 워터마크, 커스텀 자막 등 고급 기능 자유롭게 추가 가능하겠지만, 아무래도 iOS 버전별 호환성 유지보수 부담해야하고 AVPlayer 에서 제공하는 기능들을 별도로 구현이 필요하므로 개발 관련 리소스가 크다는 점이 있다.

위의 3가지 방법 중 이번 DASH 학습을 위해 2. AVAssetResourceLoader를 이용한 가상 HLS Playlist 제공 방법으로 직접 구현해보면서 해당 프로토콜에 대해서 다뤄보도록 하겠다.


.mpd 톺아보기

DASH 기반 비디오를 제공하는 URL 은 다음과 같다.

https://dash.akamaized.net/dash264/TestCasesUHD/2b/11/MultiRate.mpd

해당 URL 에 파일을 실제 다운로드 받아보면 다음과 같이 구성되어 있다. MPD(Media Presentation Description) 파일은 DASH 스트리밍에서 사용되는 파일 확장자로, 클라이언트가 재생에 필요한 모든 정보를 담고 있는 XML 매니페스트 파일이다. 실제 mpd 파일을 하나씩 뜯어보면서 각 태그의 의미를 살펴보자.

<?xml version="1.0"?>
<!-- MPD file Generated with GPAC version 0.5.2-DEV-rev1067-g9cfa0d1-master  at 2016-10-11T16:34:50.559Z-->
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" minBufferTime="PT1.500S" type="static" mediaPresentationDuration="PT0H11M58.998S" maxSegmentDuration="PT0H0M2.005S" profiles="urn:mpeg:dash:profile:isoff-live:2011,http://dashif.org/guidelines/dash264">
 <ProgramInformation moreInformationURL="http://gpac.sourceforge.net">
  <Title>../DASHed/2b/11/MultiRate.mpd generated by GPAC</Title>
 </ProgramInformation>

 <Period duration="PT0H11M58.998S">
  <AdaptationSet segmentAlignment="true" maxWidth="3840" maxHeight="2160" maxFrameRate="60000/1001" par="16:9" lang="und">
   <Representation id="1" mimeType="video/mp4" codecs="hev1.2.4.L153.90" width="3840" height="2160" frameRate="60000/1001" sar="1:1" startWithSAP="1" bandwidth="5678742">
    <SegmentTemplate timescale="60000" media="video_8000k_$Number$.mp4" startNumber="1" duration="119952" initialization="video_8000k_init.mp4"/>
   </Representation>
   <Representation id="2" mimeType="video/mp4" codecs="hev1.2.4.L153.90" width="3840" height="2160" frameRate="60000/1001" sar="1:1" startWithSAP="1" bandwidth="8308466">
    <SegmentTemplate timescale="60000" media="video_10400k_$Number$.mp4" startNumber="1" duration="119952" initialization="video_10400k_init.mp4"/>
   </Representation>
   <Representation id="3" mimeType="video/mp4" codecs="hev1.2.4.L153.90" width="3840" height="2160" frameRate="60000/1001" sar="1:1" startWithSAP="1" bandwidth="10870369">
    <SegmentTemplate timescale="60000" media="video_13520k_$Number$.mp4" startNumber="1" duration="119952" initialization="video_13520k_init.mp4"/>
   </Representation>
  </AdaptationSet>
  <AdaptationSet segmentAlignment="true" lang="eng">
   <Representation id="4" mimeType="audio/mp4" codecs="mp4a.40.2" audioSamplingRate="48000" startWithSAP="1" bandwidth="319525">
    <AudioChannelConfiguration schemeIdUri="urn:mpeg:dash:23003:3:audio_channel_configuration:2011" value="2"/>
    <SegmentTemplate timescale="48000" media="audio_64k_$Number$.mp4" startNumber="1" duration="95999" initialization="audio_64k_init.mp4"/>
   </Representation>
  </AdaptationSet>
 </Period>
</MPD>

MPD(Media Presentation Description)

<?xml version="1.0"?>
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" 
     minBufferTime="PT1.500S" 
     type="static" 
     mediaPresentationDuration="PT0H11M58.998S" 
     maxSegmentDuration="PT0H0M2.005S" 
     profiles="urn:mpeg:dash:profile:isoff-live:2011,http://dashif.org/guidelines/dash264">
  • 전체 매니페스트의 루트 엘리먼트
  • type
    - static: VOD, 전체 콘텐츠가 이미 준비되어 있음
    - dynamic: 라이브 스트리밍. 새로운 세그먼트가 계속 생성됨
  • mediaPresentationDuration
    - 전체 영상의 재생 시간. ISO 8601 기간 형식으로 표현되며, 여기서는 11분 58.998초를 의미한다
  • maxSegmentDuration="PT0H0M2.005S"
    - 세그먼트의 최대 길이. 약 2초를 의미
  • profiles
    - 이 MPD가 따르는 DASH 프로파일. 여기서는 ISOFF(ISO Base Media File Format) 라이브 프로파일과 DASH264 가이드라인을 따른다.

Period

<Period duration="PT0H11M58.998S">

Period는 콘텐츠의 시간적 구간을 나타낸다. 하나의 MPD는 여러 Period를 가질 수 있으며, 각 Period는 서로 다른 콘텐츠를 담을 수 있다.

AdaptationSet

<AdaptationSet segmentAlignment="true" 
               maxWidth="3840" 
               maxHeight="2160" 
               maxFrameRate="60000/1001" 
               par="16:9" 
               lang="und">

AdaptationSet은 동일한 콘텐츠의 여러 버전(다른 화질, 다른 언어 등)을 그룹화한다. 일반적으로 비디오와 오디오는 별도의 AdaptationSet으로 분리된다.

  • segmentAlignment
    - 모든 Representation의 세그먼트가 시간적으로 정렬되어 있다는 의미
    - true 이면 재생 중 화질 전환 시 동일한 세그먼트 번호의 타임스탬프가 일치하므로 끊김없이 전환 가능하다.

  • maxWidth="3840", maxHeight="2160"
    - 이 AdaptationSet에 포함된 모든 화질 중 최대 해상도. 4K UHD

  • maxFrameRate
    - 최대 프레임레이트
    - 60000/1001 ≈ 59.94fps 로 NTSC 방식의 60fps를 나타냄

  • par
    - Pixel Aspect Ratio. 픽셀의 가로세로 비율

Representation

<Representation id="1" 
                mimeType="video/mp4" 
                codecs="hev1.2.4.L153.90" 
                width="3840" 
                height="2160" 
                frameRate="60000/1001" 
                sar="1:1" 
                startWithSAP="1" 
                bandwidth="5678742">

Representation은 특정 비트레이트, 해상도의 실제 미디어 스트림을 나타낸다. 클라이언트는 현재 네트워크 상황에 맞는 Representation을 선택하여 재생한다. 이 MPD에는 동일한 4K 해상도에 세 가지 비트레이트가 제공된다.

  • mimeType
    - MIME 타입. 여기서는 MP4 컨테이너를 사용

  • codecs
    - 사용된 비디오 코덱
    - hev1 = HEVC (H.265) 코덱
    - 2.4.L153.90 = HEVC의 프로파일, 레벨 등 상세 정보

  • startWithSAP
    - Stream Access Point
    - 각 세그먼트가 IDR(Instantaneous Decoder Refresh) 프레임으로 시작
    - 임의의 세그먼트부터 디코딩을 시작해도 문제없다는 의미

  • bandwidth
    - 이 Representation의 비트레이트
    - 클라이언트는 이 값을 보고 현재 네트워크에서 재생 가능한지 판단

SegmentTemplate

SegmentTemplate은 세그먼트 파일의 URL 패턴과 타이밍 정보를 정의한다. 템플릿 방식을 사용하면 수백 개의 세그먼트를 일일이 나열하지 않아도 된다.

<SegmentTemplate timescale="60000" 
                 media="video_8000k_$Number$.mp4" 
                 startNumber="1" 
                 duration="119952" 
                 initialization="video_8000k_init.mp4"/>
  • timescale
    - 시간 단위의 기준
    - 여기서는 1초에 60000 단위로 표현

  • media
    - 세그먼트 파일명 템플릿
    - $Number$는 세그먼트 번호로 치환된다
    - ex: video_8000k_1.mp4, video_8000k_2.mp4, ...

  • startNumber
    - 시작 세그먼트 번호

  • duration
    - 각 세그먼트의 재생 시간 (timescale 단위)

  • initialization
    - 초기화 세그먼트
    - 재생 전 먼저 다운로드해야 하는 파일
    - 코덱 정보, 메타데이터 등이 포함됨

오디오

오디오를 위한 별도의 AdaptationSet이다. 비디오와 오디오를 분리하는 이유는

  1. 독립적인 ABR (화질은 낮추되 오디오 품질은 유지)
  2. 다국어 지원 (영어, 한국어, 일본어 오디오 선택 가능)
  3. 접근성 (음성 해설 트랙 추가 가능)

해당 MPD 파일에 하단부분에 위치한 것을 확인할 수 있다.

AudioChannelConfiguration

<AudioChannelConfiguration schemeIdUri="urn:mpeg:dash:23003:3:audio_channel_configuration:2011" 
                            value="2"/>
  • value: 채널
    - 1 = 모노
    - 2 = 스테레오
    - 6 = 5.1 서라운드
    - 8: 7.1 서라운드

정리

이 MPD 파일은 다음을 제공한다:

  • 비디오: 4K 60fps HEVC, 3가지 비트레이트 (5.68/8.31/10.87Mbps)
  • 오디오: 스테레오 AAC 48kHz, 320kbps
  • 세그먼트: 약 2초 단위, 총 359개
  • 재생 시간: 11분 58초
  • ABR 지원: 네트워크 상황에 따라 자동 화질 전환
  • 탐색 지원: 각 세그먼트가 독립적으로 디코딩 가능

이후 내용은 해당 MPD 파일을 기준으로 AVAssetResourceLoader 를 이용해 가상의 HLS Playlist 로 변환하여 AVPlayer 가 해당 비디오 세그먼트가 있는 URL 로 리다이렉트해서 직접 받아오는 식으로 재생하게끔 구성할 것이다.


Model 정의

DASH MPD 파일은 XML 형식에 계층적 구조를 가지고 있다보니 이를 그대로 반영하기 위해 모델 정의도 Nested Struct로 구성하였다. 해당 모델 파일은 Player/Models/DASH/DASHMPD.swift 에서 확인할 수 있다.

  • DASHMPD 최상위 구조체
    전체 비디오 프레젠테이션을 나타낸다.
struct DASHMPD {
    let baseURL: URL              // MPD 파일의 URL (세그먼트 URL 계산에 사용)
    let type: PresentationType    // static(VOD) or dynamic(Live)
    let mediaPresentationDuration: TimeInterval?  // 전체 영상 길이
    let minBufferTime: TimeInterval  // 최소 버퍼 시간
    let periods: [Period]         // Period 배열
    
    enum PresentationType: String {
        case static_ = "static"
        case dynamic = "dynamic"
    }

}
  • Period(구간)
    영상의 특정 시간 구간 역할을 한다. 보통 광고 삽입 등에 사용된다. 대부분의 경우 VOD는 Period가 1개만 있으나
    Live나 광고 있는 경우 여러 Period로 나뉠 수 있다.
struct DASHMPD {
    ...
    struct Period {
        let id: String?
        let duration: TimeInterval?
        let adaptationSets: [AdaptationSet] // 비디오, 오디오, 자막 등
    }
}
  • AdaptationSet
    같은 타입의 미디어를 그룹화한다. 이는 비디오/오디오/자막을 분리하여 독립적으로 품질 선택 가능하다.
struct DASHMPD {
	...
    struct AdaptationSet {
        let id: String?
        let contentType: String?  // "video", "audio", "text"
        let mimeType: String?    // "video/mp4", "audio/mp4"
        let codecs: String?      // "avc1.640028", "mp4a.40.2"
        let representations: [Representation]
    }
}
  • Representation
    특정 화질/음질의 스트림이다. ABR은 네트워크 상황에 따라 Representation을 전환한다.
class DASHMPD {
	...
    struct Representation {
        let id: String              
        let bandwidth: Int          // 800000 (800kbps), 3000000 (3Mbps)
        let width: Int?             // 1920 (비디오만 해당)
        let height: Int?            // 1080 (비디오만 해당)
        let frameRate: String?      // "30", "60" 등
        let codecs: String?
        let mimeType: String?
        let segmentTemplate: SegmentTemplate?  // 세그먼트 URL 패턴
        let segmentList: SegmentList?          // 또는 세그먼트 목록
        let baseURL: String?
    }
}
  • SegmentTemplate
    세그먼트 URL 생성 규칙이다.
class DASHMPD {
	...
    struct SegmentTemplate {
        let initialization: String?  // "video_8000k_init.mp4"
        let media: String?           // "video_8000k_$Number$.mp4"
        let timescale: Int?          // 90000 (시간 단위)
        let duration: Int?           // 180000 (timescale 단위)
        let startNumber: Int?        // 1 (시작 번호)
    }
}
  • SegmentList
    SegmentTemplate 대신 직접 URL 목록을 제공한다. SegmentTemplate 는 규칙적인 패턴으로 URL 생성하지만 SegmentList 는 각 세그먼트 URL을 직접 나열하기 때문에 불규칙적이라는 점에서 다르다.
class DASHMPD {
	...
    struct SegmentList {
       let initialization: Initialization?
       let segments: [SegmentURL]   // 세그먼트 URL 배열
       let timescale: Int?
       let duration: Int?
    }
}

DASHParser

  • Utilities/DASHParser.swift
    서버에서 받은 XML 문자열(<MPD>...</MPD>)을 읽어서 DASHMPD 객체로 변환한다.
class DASHParser {
	...
    func parse(_ xmlString: String, baseURL: URL) throws -> DASHMPD {
        self.baseURL = baseURL
        reset()

        guard let data = xmlString.data(using: .utf8) else {
            throw ParsingError.invalidXML
        }

        let parser = XMLParser(data: data)
        parser.delegate = self

        guard parser.parse() else {
            throw ParsingError.invalidXML
        }

        guard !periods.isEmpty else {
            throw ParsingError.unsupportedFormat
        }

        return DASHMPD(
            baseURL: baseURL,
            type: mpdType,
            mediaPresentationDuration: mediaPresentationDuration,
            minBufferTime: minBufferTime,
            periods: periods
        )
    }
}

DASHResourceLoaderDelegate

AVAssetResourceLoaderDelegate 의 역할에 대해서는 이미 지난 포스트에서 다루었다.

AVPlayer는 기본적으로 DASH를 직접 재생할 수 없기 때문에, DASH MPD를 HLS Playlist 형태로 변환한 가상 Playlist를 제공해야 한다. 따라서 해당 Delegate는 custom scheme을 사용해 AVPlayer의 요청을 가로채고, HLS Playlist 요청에는 가상의 .m3u8을 반환하며 세그먼트 요청은 실제 DASH 세그먼트 URL로 리다이렉트한다. 이를 통해 AVPlayer 위에서 DASH 스트리밍을 우회적으로 재생할 수 있다.

Property

class DASHResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate {

    // MARK: - Properties

    private let customScheme = "custom-dash"
    private let mpd: DASHMPD
    private let representation: DASHMPD.Representation
    private let virtualPlaylist: String

AVAssetResourceLoaderDelegate

// MARK: - AVAssetResourceLoaderDelegate

func resourceLoader(
    _ resourceLoader: AVAssetResourceLoader,
    shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest
) -> Bool {
    guard let url = loadingRequest.request.url else {
        loadingRequest.finishLoading(with: NSError(domain: "DASHResourceLoader", code: -1))
        return false
    }

    handleLoadingRequest(loadingRequest, url: url)

    return true
}

Private Methods

  • handleLoadingRequest(_ loadingRequest: AVAssetResourceLoadingRequest, url: URL)
    가상 플레이리스트 -> init 세그먼트 -> 나머지 비디오 세그먼트 이 순서로 처리되기 때문에 if-else 분기 순서대로 처리가 된다.
private func handleLoadingRequest(_ loadingRequest: AVAssetResourceLoadingRequest, url: URL) {
    do {
        let urlString = url.absoluteString

        if urlString.hasSuffix("manifest.m3u8") {
            // 가상 HLS 플레이리스트
            loadManifest(loadingRequest)
        } else if urlString.contains("init") {
            // 초기화 세그먼트
            try loadInitializationSegment(loadingRequest, url: url)
        } else {
            // 미디어 세그먼트
            try loadMediaSegment(loadingRequest, url: url)
        }
    } catch {
        loadingRequest.finishLoading(with: error as NSError)
    }
}
  • loadManifest(_ loadingRequest: AVAssetResourceLoadingRequest)
    AVPlayer 에 가상의 HLS 플레이리스트를 가지고 응답하는 메소드이다.
private func loadManifest(_ loadingRequest: AVAssetResourceLoadingRequest) {
    guard let data = virtualPlaylist.data(using: .utf8) else {
        loadingRequest.finishLoading(with: NSError(domain: "DASHResourceLoader", code: -7))
        return
    }

    if let contentRequest = loadingRequest.contentInformationRequest {
        contentRequest.contentType = "application/x-mpegURL"
        contentRequest.isByteRangeAccessSupported = false
        contentRequest.contentLength = Int64(data.count)
    }

    if let dataRequest = loadingRequest.dataRequest {
        dataRequest.respond(with: data)
    }

    loadingRequest.finishLoading()
}
  • loadInitializationSegment(_ loadingRequest: AVAssetResourceLoadingRequest, url: URL)
    초기화 세그먼트(fMP4의 헤더 정보)를 실제 HTTPS URL로 리다이렉트한다. AVPlayerhttp/https 스킴만 처리가 가능하고 리다이렉트함으로써 AVPlayerURLSession 을 통해서 직접 비디오 세그먼트를 받아오게 처리를 해야 재생이 되기 때문이다.
    private func loadInitializationSegment(_ loadingRequest: AVAssetResourceLoadingRequest, url: URL) throws {
        // URL 경로 추출 - manifest.m3u8가 경로에 포함되어 있으면 제거
        var path = url.path
        if path.hasPrefix("/manifest.m3u8/") {
            path = String(path.dropFirst("/manifest.m3u8/".count))
        } else if path.hasPrefix("/") {
            path = String(path.dropFirst())
        } else if path.isEmpty {
            path = url.host ?? ""
        }

        // MPD baseURL과 결합하여 실제 URL 생성
        let initURL = URL(string: path, relativeTo: mpd.baseURL.deletingLastPathComponent())!

        // 302 리다이렉트 응답 반환 (데이터 직접 제공하지 않음)
        loadingRequest.redirect = URLRequest(url: initURL)
        loadingRequest.response = HTTPURLResponse(
            url: initURL,
            statusCode: 302,
            httpVersion: "HTTP/1.1",
            headerFields: ["Location": initURL.absoluteString]
        )

        loadingRequest.finishLoading()
    }
  • loadMediaSegment(_ loadingRequest: AVAssetResourceLoadingRequest, url: URL)
    미디어 세그먼트도 위의 초기화 세그먼트와 동일하게 실제 HTTPS URL로 리다이렉트 해야한다.
    private func loadMediaSegment(_ loadingRequest: AVAssetResourceLoadingRequest, url: URL) throws {
        // URL 경로 추출 - manifest.m3u8가 경로에 포함되어 있으면 제거
        var path = url.path
        if path.hasPrefix("/manifest.m3u8/") {
            path = String(path.dropFirst("/manifest.m3u8/".count))
        } else if path.hasPrefix("/") {
            path = String(path.dropFirst())
        } else if path.isEmpty {
            path = url.host ?? ""
        }

        // MPD baseURL과 결합하여 실제 URL 생성
        let mediaURL = URL(string: path, relativeTo: mpd.baseURL.deletingLastPathComponent())!

        // 302 리다이렉트 응답 반환
        loadingRequest.redirect = URLRequest(url: mediaURL)
        loadingRequest.response = HTTPURLResponse(
            url: mediaURL,
            statusCode: 302,
            httpVersion: "HTTP/1.1",
            headerFields: ["Location": mediaURL.absoluteString]
        )

        loadingRequest.finishLoading()
    }

StreamPlayerManager

  • generateVirtualHLSPlaylist()
    DASH의 SegmentTemplate 정보를 HLS 플레이리스트 형식의 텍스트로 변환한다. 각 태그별 의미는 이전 게시글 에서 다루었으니 참고하길 바란다.
    /// DASH를 HLS 형식의 가상 플레이리스트로 변환
    private func generateVirtualHLSPlaylist(
        mpd: DASHMPD,
        representation: DASHMPD.Representation,
        segmentCount: Int
    ) -> String {
        guard let template = representation.segmentTemplate,
              let duration = template.duration,
              let timescale = template.timescale else {
            return ""
        }

        let segmentDuration = Double(duration) / Double(timescale)
        let startNumber = template.startNumber ?? 1

        var playlist = "#EXTM3U\n"
        playlist += "#EXT-X-VERSION:6\n"
        playlist += "#EXT-X-TARGETDURATION:\(Int(ceil(segmentDuration)))\n"
        playlist += "#EXT-X-MEDIA-SEQUENCE:0\n"
        playlist += "#EXT-X-PLAYLIST-TYPE:VOD\n"
        playlist += "#EXT-X-INDEPENDENT-SEGMENTS\n"

        // 초기화 세그먼트
        if let initPattern = template.initialization {
            playlist += "#EXT-X-MAP:URI=\"\(initPattern)\"\n"
        }

        // 미디어 세그먼트
        if let mediaPattern = template.media {
            for i in 0..<segmentCount {
                let segmentNumber = startNumber + i
                // $Number$를 실제 숫자로 교체
                let segmentURL = mediaPattern.replacingOccurrences(of: "$Number$", with: "\(segmentNumber)")
                playlist += "#EXTINF:\(segmentDuration),\n"
                playlist += "\(segmentURL)\n"
            }
        }

        playlist += "#EXT-X-ENDLIST\n"

        return playlist
    }
  • loadDASH(mpdURL: URL, bandwidth: Int = 3_000_000)
    다음과 같이 크게 4가지로 구성할 수 있다. MPD 파일을 받아와서 대역폭에 따른 Representation 을 로드하고, AVPlayer 에서 호환 가능한 형태의 가상의 HLS Playlist 로 변환한 뒤 Delegate 에 넘길 수 있도록 커스텀 스킴을 적용한다.

▲ 그림 1. loadDash 플로우

/// DASH 스트림 로드 (AVAssetResourceLoader 방식)
func loadDASH(mpdURL: URL, bandwidth: Int = 3_000_000) async throws {
    cleanup()
    currentState = .loading

    // 1. MPD 파일 다운로드
    let (data, _) = try await URLSession.shared.data(from: mpdURL)

    guard let xmlString = String(data: data, encoding: .utf8) else {
        throw NSError(domain: "StreamPlayerManager", code: -1, userInfo: [
            NSLocalizedDescriptionKey: "Failed to decode MPD file"
        ])
    }

    // 2. MPD 파싱
    let parser = DASHParser()
    let mpd = try parser.parse(xmlString, baseURL: mpdURL)

    // 3. Representation 선택
    guard let representation = mpd.selectRepresentation(bandwidth: bandwidth) else {
        throw NSError(domain: "StreamPlayerManager", code: -2, userInfo: [
            NSLocalizedDescriptionKey: "Failed to select representation"
        ])
    }

    // 4. 세그먼트 개수 계산
    guard let segmentCount = mpd.totalSegmentCount(for: representation),
          segmentCount > 0 else {
        throw NSError(domain: "StreamPlayerManager", code: -3, userInfo: [
            NSLocalizedDescriptionKey: "Failed to calculate segment count"
        ])
    }

    // 5. HLS 형식의 가상 플레이리스트 생성
    let virtualPlaylist = generateVirtualHLSPlaylist(
        mpd: mpd,
        representation: representation,
        segmentCount: segmentCount
    )

    // 6. ResourceLoaderDelegate 생성 및 설정
    let delegate = DASHResourceLoaderDelegate(
        mpd: mpd,
        representation: representation,
        virtualPlaylist: virtualPlaylist
    )
    self.dashResourceLoaderDelegate = delegate

    // 7. Custom scheme URL 생성
    let customURL = URL(string: "custom-dash://manifest.m3u8")!

    // 8. AVURLAsset 생성 및 ResourceLoader 설정
    let asset = AVURLAsset(url: customURL)
    asset.resourceLoader.setDelegate(
        delegate,
        queue: DispatchQueue(label: "com.dashplayer.resourceloader")
    )

    // 9. AVPlayerItem 및 AVPlayer 생성
    playerItem = AVPlayerItem(asset: asset)
    player = AVPlayer(playerItem: playerItem)

    if let player = player {
        player.automaticallyWaitsToMinimizeStalling = true
    }

    setupObservers()
}

총정리

DASH 재생 전체 흐름은 다음과 같다. 이미 StreamPlayerManager 에서 해당 DASH URL은 커스텀 스킴이 적용된 URL 형태로 변환되었기 때문에, (1) AVPlayer 에서 해당 URL 을 재생 요청을 보내면 DASHResourceLoaderDelegate 로 넘어가게 된다. 이 때 (2) Delegate 의 handleLoadingRequest() -> loadManifest() 를 호출하면서 가상 HLS 플레이리스트 텍스트를 data 로 응답하게 된다. (3) AVPlayer 가 플레이리스트로 파싱하면서 플레이리스트 안에 상대주소로 명시된 초기화 세그먼트 URL 을 다시 요청하게 된다. 해당 URL은 마찬가지로 custom scheme이 적용되어 있어 마찬가지로 (4) Delegate 의 resourceLoader() 를 호출하게 되는데, 해당 경로를 다시 https:// 스킴의 실제 초기화 세그먼트가 있는 URL 로 변환하게 되고, (5) AVPlayer 에서 직접 302 리다이렉트를 통해 직접 다운로드해서 받아오게 된다. 나머지 비디오 세그먼트들도 동일하다. (6) AVPlayer 가 커스텀 스킴의 비디오 세그먼트 URL 들을 처리를 못해 Delegate에 넘기게 되고 (7) Delegate 에서는 마찬가지로 실제 해당 비디오 세그먼트들이 저장된 URL로 변환하고 AVPlayer 가 리다이렉트함으로써 순차적으로 받아와 재생하게 된다.

▲ 그림 2. AVPlayer와 Delegate 플로우


결과

해당 프로젝트의 코드는 다음 링크 에서 모두 확인할 수 있다.

▲ 그림 3. DASH 플레이어 실행 결과


Reference

profile
모바일 개발에 관하여 이것, 저것 다 합니다.

0개의 댓글