Successfully reported this slideshow.
We use your LinkedIn profile and activity data to personalize ads and to show you more relevant ads. You can change your ad preferences anytime.

iOSDC 2018 動画をなめらかに動かす技術

2,706 views

Published on

iOSDC 2018 動画をなめらかに動かす技術

Published in: Technology
  • Be the first to comment

iOSDC 2018 動画をなめらかに動かす技術

  1. 1. 動画アプリをなめらかに動かす技術 Yuji Hato iOSDC 2018
  2. 2. Yuji Hato CyberAgent, Inc. / AbemaTV, Inc. dekatotoro @dekatotoro Contributed services About me
  3. 3. 動画再生 HLS 実装ポイント 最適なbitrate プレイヤーの監視 Agenda
  4. 4. 動画再生
  5. 5. Download Progressive Download Streaming 動画再生 HTTP Adaptive Streaming Local File 動画コンテンツ
  6. 6. Download Progressive Download Streaming HTTP Adaptive Streaming Local File 動画コンテンツ 動画再生
  7. 7. HDS (HTTP Dynamic Streaming) … Adobe SS (Smooth Streaming) … Microsoft MPEG-DASH(Dynamic Adaptive Streaming over HTTP) … ISO/IEC 23009 HLS (HTTP Live Streaming) … Apple HTTP Adaptive Streaming プロトコル 動画再生
  8. 8. HDS (HTTP Dynamic Streaming) … Adobe SS (Smooth Streaming) … Microsoft MPEG-DASH(Dynamic Adaptive Streaming over HTTP) … ISO/IEC 23009 HLS (HTTP Live Streaming) … Apple HTTP Adaptive Streaming プロトコル 動画再生
  9. 9. Container Format / codec Container Format Video codec Audio codec MPEG2-TS .ts/m2t/.m2ts H.264/MPEG-2 AAC/AC-3/MP3 MP4 .mp4/.m4a H.264/Xvid/Divx//MPEG-4/H.265 AAC/MP3/AC-3/Voribis MOV .mov/.qt H.264/MJEG/MPEG-4 AAC/MP3/LPCM AVI .avi H.264/Xvid/Divx/MPEG-4 AAC/MP3/LPCM WebM .webm VP8/VP9 Vorbis etc 動画再生
  10. 10. Container Format / codec Container Format Video codec Audio codec MPEG2-TS .ts/m2t/.m2ts H.264/MPEG-2 AAC/AC-3/MP3 MP4 .mp4/.m4a H.264/Xvid/Divx//MPEG-4/H.265/AV1 AAC/MP3/AC-3/Voribis MOV .mov/.qt H.264/MJEG/MPEG-4 AAC/MP3/LPCM AVI .avi H.264/Xvid/Divx/MPEG-4 AAC/MP3/LPCM WebM .webm VP8/VP9/AV1 Vorbis etc 動画再生
  11. 11. Encoder Server System CDN Live 動画再生
  12. 12. VOD Encoder Server System CDN動画ソース 動画再生
  13. 13. HLS
  14. 14. Media Playlist HLS Master Playlist Playlist
  15. 15. #EXTM3U #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=232370,CODECS="mp4a.40.2, avc1.4d4015" gear1/prog_index.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=649879,CODECS="mp4a.40.2, avc1.4d401e" gear2/prog_index.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=991714,CODECS="mp4a.40.2, avc1.4d401e" gear3/prog_index.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1927833,CODECS="mp4a.40.2, avc1.4d401f" gear4/prog_index.m3u8 … HLS https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_4x3/bipbop_4x3_variant.m3u8 Master Playlist (.m3u8)
  16. 16. #EXTM3U #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=232370,CODECS="mp4a.40.2, avc1.4d4015" gear1/prog_index.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=649879,CODECS="mp4a.40.2, avc1.4d401e" gear2/prog_index.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=991714,CODECS="mp4a.40.2, avc1.4d401e" gear3/prog_index.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1927833,CODECS="mp4a.40.2, avc1.4d401f" gear4/prog_index.m3u8 … HLS https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_4x3/bipbop_4x3_variant.m3u8 Master Playlist (.m3u8)
  17. 17. HLS Master Playlist (.m3u8)
  18. 18. HLS Header playlist1.m3u8 playlist2.m3u8 Playlist3.m3u8 Playlist4.m3u8 BANDWIDTH=232370 BANDWIDTH=649879 BANDWIDTH=991714 BANDWIDTH=1927833 Master Playlist (.m3u8) CODECS=xxxx CODECS=xxxx CODECS=xxxx CODECS=xxxx
  19. 19. #EXTM3U #EXT-X-TARGETDURATION:10 #EXT-X-VERSION:3 #EXT-X-MEDIA-SEQUENCE:0 #EXT-X-PLAYLIST-TYPE:VOD #EXTINF:9.97667, fileSequence0.ts #EXTINF:9.97667, fileSequence1.ts #EXTINF:9.97667, fileSequence2.ts #EXTINF:9.97667, fileSequence3.ts #EXTINF:9.97667, fileSequence4.ts … #EXT-X-ENDLIST HLS http://devimages.apple.com/iphone/samples/bipbop/gear1/prog_index.m3u8 Media Playlist (.m3u8)
  20. 20. #EXTM3U #EXT-X-TARGETDURATION:10 #EXT-X-VERSION:3 #EXT-X-MEDIA-SEQUENCE:0 #EXT-X-PLAYLIST-TYPE:VOD #EXTINF:9.97667, fileSequence0.ts #EXTINF:9.97667, fileSequence1.ts #EXTINF:9.97667, fileSequence2.ts #EXTINF:9.97667, fileSequence3.ts #EXTINF:9.97667, fileSequence4.ts … #EXT-X-ENDLIST HLS http://devimages.apple.com/iphone/samples/bipbop/gear1/prog_index.m3u8 Media Playlist (.m3u8)
  21. 21. #EXTM3U #EXT-X-TARGETDURATION:10 #EXT-X-VERSION:3 #EXT-X-MEDIA-SEQUENCE:0 #EXT-X-PLAYLIST-TYPE:VOD #EXTINF:9.97667, fileSequence0.ts #EXTINF:9.97667, fileSequence1.ts #EXTINF:9.97667, fileSequence2.ts #EXTINF:9.97667, fileSequence3.ts #EXTINF:9.97667, fileSequence4.ts … #EXT-X-ENDLIST ts セグメントファイル (.ts) HLS http://devimages.apple.com/iphone/samples/bipbop/gear1/prog_index.m3u8 Media Playlist (.m3u8) ts ts ts ts
  22. 22. HLS Header segment1.ts segment2.ts segment3.ts segment4.ts length=10sec Footer length=10sec length=10sec length=10sec Media Playlist (.m3u8)
  23. 23. HLS https://developer.apple.com/streaming/ Documents
  24. 24. 実装ポイント
  25. 25. 実装ポイント 動画再生 UIWebView / WKWebView WKWebVIewはiOS8~ MPMoviePlayerController iOS2 ~ iOS9 AVPlayer iOS4~ AVPlayerViewController iOS8~ Other Metalなど
  26. 26. 実装ポイント 動画再生 UIWebView / WKWebView WKWebVIewはiOS8~ MPMoviePlayerController iOS2 ~ iOS9 AVPlayer iOS4~ AVPlayerViewController iOS8~ Other Metalなど
  27. 27. 実装ポイント 動画再生 コンポーネント AVPlayer AVAsset AVPlayerViewController iOS/tvOS AVPlayerView macOS AVPlayerLayer AVPlayerItem
  28. 28. AVAsset 実装ポイント 動画再生の流れ AVPlayerItem URL AVPlayer AVPlayerLayer UIView
  29. 29. 実装ポイント 動画再生 let asset = AVAsset(url: url) let playerItem = AVPlayerItem(asset: asset) let player = AVPlayer(playerItem: playerItem) playerView.player = player 動画
  30. 30. 実装ポイント 最適化する 動画再生
  31. 31. 実装ポイント 動画 動画再生 動画 動画動画動画 動画
  32. 32. 実装ポイント 動画A 動画再生
  33. 33. 実装ポイント 動画B thumbnail 動画再生 動画A 動画A破棄
  34. 34. 実装ポイント 動画B thumbnail 動画再生 動画A thumbnail 動画A破棄
  35. 35. 実装ポイント 動画B thumbnail 動画再生 動画A thumbnail 動画B生成
  36. 36. 実装ポイント 動画B 動画再生 動画A thumbnail 動画B生成
  37. 37. 実装ポイント 動画B 動画再生
  38. 38. 実装ポイント 実装 動画再生
  39. 39. 実装ポイント Playback start process https://developer.apple.com/videos/play/wwdc2018/502/
  40. 40. 実装ポイント Playback start process https://developer.apple.com/videos/play/wwdc2018/502/
  41. 41. 実装ポイント VideoPlayer class class VideoPlayer { private(set) var player: AVPlayer! let playerItem: AVPlayerItem init(url: URL) { let asset: AVAsset = AVAsset(url: url) playerItem = AVPlayerItem(asset: asset) } … }
  42. 42. 実装ポイント サブスレッドでPlayerを生成 class VideoPlayer { init(url: URL) { let asset: AVAsset = AVAsset(url: url) playerItem = AVPlayerItem(asset: asset) } … } // サブスレッドでプレイヤーを生成 streamURL.asObservable() .observeOn(scheduler) .subscribe(onNext: { [weak self] streamURL in self?.createPlayer(url: streamURL) // createPlayerでVideoPlayerを生成 }) .disposed(by: disposeBag)
  43. 43. AVAsset 実装ポイント サブスレッドでAVAssetとAVPlayerItemを生成 AVPlayerItem URL AVPlayer AVPlayerLayer UIView サブスレッド
  44. 44. 実装ポイント RxでAVAssetのloadValuesAsynchronouslyをobserve extension Reactive where Base: AVAsset { var isPlayable: Observable<Bool> { return Observable.create { [weak base] observer in base?.loadValuesAsynchronously(forKeys: [#keyPath(AVAsset.isPlayable)]) { if let me = base { observer.onNext(me.isPlayable) } } return Disposables.create() } } … }
  45. 45. 実装ポイント RxでAVPlayerItemのKVO, notificationなどをobserve extension Reactive where Base: AVPlayerItem { var status: Observable<AVPlayerItemStatus> { return observe(AVPlayerItemStatus.self, #keyPath(AVPlayerItem.status)).filterNil() } var seekableTimeRanges: Observable<[NSValue]> { return observe([NSValue].self, #keyPath(AVPlayerItem.seekableTimeRanges)).filterNil() } func asEndTimeObervable(notification: NotificationCenter = .default) -> Observable<Void> { return notification.rx.notification(.AVPlayerItemDidPlayToEndTime, object: base).map(void) } … }
  46. 46. 実装ポイント class VideoPlayer { … init(url: URL) { let asset: AVAsset = AVAsset(url: url) playerItem = AVPlayerItem(asset: asset) asset.rx.isPlayable .filter { $0 } .observeOn(ConcurrentMainScheduler.instance) .subscribe(onNext: { [weak self] _ in guard let me = self else { return } // メインスレッドで生成 me.player = AVPlayer(playerItem: playerItem) }) .disposed(by: disposeBag) } … } AVAssetのplayableのtrueを待ってAVPlayerを生成
  47. 47. 実装ポイント AVPlayerを生成後、Playerの状態をobserve // サブスレッド asset.rx.isPlayable .filter { $0 } .observeOn(ConcurrentMainScheduler.instance) .subscribe(onNext: { [weak self] _ in // メインスレッド guard let me = self else { return } me.player = AVPlayer(playerItem: playerItem) me.observePlayer() me.playerCreated.accept(()) }) .disposed(by: disposeBag)
  48. 48. AVAsset 実装ポイント メインスレッドでAVPlayerを生成 AVPlayerItem URL AVPlayer AVPlayerLayer UIView サブスレッド メインスレッ ド
  49. 49. 実装ポイント AVPlayer生成処理など全てサブスレッドは? // サブスレッド asset.rx.isPlayable .filter { $0 } .subscribe(onNext: { [weak self] _ in // サブスレッド guard let me = self else { return } me.player = AVPlayer(playerItem: playerItem) me.observePlayer() me.playerCreated.accept(()) }) .disposed(by: disposeBag) 注: AVPlayerのKVOはmain thread推奨
  50. 50. 実装ポイント playerの状態をobserve private func observePlayer() { player.rx.periodicTime(for: CMTime(seconds: 1.0, preferredTimescale: Int32(NSEC_PER_SEC))) .bind(to: periodicTime) .disposed(by: disposeBag) playerItem.rx.errorStatus .bind(to: playerItemError) .disposed(by: disposeBag) playerItem.rx.asEndTimeObervable() .bind(to: playerItemEndTime) .disposed(by: disposeBag) … }
  51. 51. 実装ポイント AVPlayerItem.statusがreadyToPlayになるを待って再生開始 private func observePlayer() { … Observable.combineLatest(asset.rx.isPlayable, playerItem.rx.status, playerCreated) .map { $0.0 && $0.1 == .readyToPlay } .bind(to: isPlayableAndReadyToPlay) .disposed(by: disposeBag) … }
  52. 52. AVAsset 実装ポイント AVPlayerLayer AVPlayerItem URL AVPlayer AVPlayerLayer UIView
  53. 53. 実装ポイント RxでAVPlayerLayerのreadyForDisplayをobserve extension Reactive where Base: AVPlayerLayer { var ready: Observable<Void> { return observe(Bool.self, #keyPath(AVPlayerLayer.isReadyForDisplay), options: .init(rawValue: 0), retainSelf: false) .filter { _ in self.base.isReadyForDisplay } .map(void) } … }
  54. 54. 実装ポイント AVPlayerLayerのreadyForDisplayを待って表示 playerLayer.isHidden = true playerLayer.rx.ready .observeOn(ConcurrentMainScheduler.instance) .subscribe(onNext: { [weak self] _ in self?.playerLayer.isHidden = false }) .disposed(by: rx.disposeBag)
  55. 55. 実装ポイント AVPlayerとAVPlayerLayerはサブスレッドで破棄 deinit { let layer = playerLayer DispatchQueue.global(qos: .background).async { layer.videoPlayer?.dispose() layer.videoPlayer = nil layer.player = nil } }
  56. 56. 実装ポイント 動画B thumbnail 動画再生 動画A 動画A破棄 動画B生成 なめらかに!
  57. 57. 実装ポイント UI/UX × 動画再生 の最適なバランスを模索する 動画再生
  58. 58. 最適なbitrate
  59. 59. 最適なbitrate preferredPeakBitRate AVPlayerItem.preferredPeakBitRate
  60. 60. #EXTM3U #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=232370,CODECS="mp4a.40.2, avc1.4d4015" gear1/prog_index.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=649879,CODECS="mp4a.40.2, avc1.4d401e" gear2/prog_index.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=991714,CODECS="mp4a.40.2, avc1.4d401e" gear3/prog_index.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1927833,CODECS="mp4a.40.2, avc1.4d401f" gear4/prog_index.m3u8 … https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_4x3/bipbop_4x3_variant.m3u8 Master Playlist (.m3u8) 最適なbitrate
  61. 61. #EXTM3U #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=232370,CODECS="mp4a.40.2, avc1.4d4015" gear1/prog_index.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=649879,CODECS="mp4a.40.2, avc1.4d401e" gear2/prog_index.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=991714,CODECS="mp4a.40.2, avc1.4d401e" gear3/prog_index.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1927833,CODECS="mp4a.40.2, avc1.4d401f" gear4/prog_index.m3u8 … https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_4x3/bipbop_4x3_variant.m3u8 Master Playlist (.m3u8) 最適なbitrate
  62. 62. 最適なbitrate 動画サイズに応じた最適化 縦 横 iPhone
  63. 63. 最適なbitrate 動画サイズに応じた最適化 iPad 縦 横
  64. 64. 最適なbitrate 端末解像度応じた最適化 https://www.apple.com/jp/ios/ios-11/
  65. 65. 最適なbitrate preferredPeakBitRate 端末解像度 × 動画サイズ による最適なbitrateを指定する
  66. 66. Playerの監視
  67. 67. Playerの監視 HLS Playback Session https://developer.apple.com/videos/play/wwdc2018/502/
  68. 68. Playerの監視 HLS Playback Session https://developer.apple.com/videos/play/wwdc2018/502/
  69. 69. AVPlayerItemErrorLog Playerの監視 AVPlayerItemAccessLog Playerの情報 AVPlayerItem Notification AVPlayer
  70. 70. Playerの監視 Notification public static let AVPlayerItemTimeJumped: NSNotification.Name public static let AVPlayerItemDidPlayToEndTime: NSNotification.Name public static let AVPlayerItemFailedToPlayToEndTime: NSNotification.Name public static let AVPlayerItemPlaybackStalled: NSNotification.Name public static let AVPlayerItemNewAccessLogEntry: NSNotification.Name public static let AVPlayerItemNewErrorLogEntry: NSNotification.Name
  71. 71. Playerの監視 Notification extension Reactive where Base: AVPlayerItem { var failedToPlay: Observable<Void> { return NotificationCenter.default.rx.notification(.AVPlayerItemFailedToPlayToEndTime, object: base) .map(void) } var stalled: Observable<Void> { return NotificationCenter.default.rx.notification(.AVPlayerItemPlaybackStalled, object: base) .map(void) } … }
  72. 72. Playerの監視 Notification extension Notification.Name { static let timebaseEffectiveRateChanged = NSNotification.Name(rawValue: String(kCMTimebaseNotification_EffectiveRateChanged)) } var timebaseEffectiveRateChanged: Observable<Float64> { return NotificationCenter.default.rx.notification(.timebaseEffectiveRateChanged, object: base.timebase) .flatMap { [weak base] _ -> Observable<Float64> in guard let timebase = base?.timebase else { return .empty() } return .just(CMTimebaseGetRate(timebase)) } }
  73. 73. AVPlayerItem - AVPlayerItemStatus public enum AVPlayerItemStatus : Int { case unknown case readyToPlay case failed } Playerの監視
  74. 74. AVPlayerItem - AVPlayerItemStatus extension Reactive where Base: AVPlayerItem { var status: Observable<AVPlayerItemStatus> { return observe(AVPlayerItemStatus.self, #keyPath(AVPlayerItem.status)).filterNil() } } playerItem.rx.status, .filter { $0 == AVPlayerItemStatus.failed } .subscribe(onNext: { [weak playerItem] _ in if let event = playerItem?.errorLog()?.events.last { print("(event.errorStatusCode): (event.errorDomain): (event.errorComment ?? "-")") } } Playerの監視
  75. 75. AVPlayerItem - playback buffer isPlaybackLikelyToKeepUp isPlaybackBufferFull isPlaybackBufferEmpty extension Reactive where Base: AVPlayerItem { var isPlaybackLikelyToKeepUp: Observable<Bool> { return observe(Bool.self, #keyPath(AVPlayerItem.isPlaybackLikelyToKeepUp) ).filterNil() } var isPlaybackBufferFull: Observable<Bool> { return observe(Bool.self, #keyPath(AVPlayerItem.isPlaybackBufferFull)).filterNil() } var isPlaybackBufferEmpty: Observable<Bool> { return observe(Bool.self, #keyPath(AVPlayerItem.isPlaybackBufferEmpty)).filterNil() } … } Playerの監視
  76. 76. AVPlayerItem - playback buffer extension Reactive where Base: AVPlayerItem { var loadedTimeRanges: Observable<[NSValue]> { return observe([NSValue].self, #keyPath(AVPlayerItem.loadedTimeRanges)).filterNil() } } playerItem.rx.loadedTimeRanges .subscribe(onNext: { [weak playerItem] loadedTimeRanges in guard let playerItem = playerItem else { return } let buffer: Double = (loadedTimeRanges.last as? CMTimeRange) .map { range in (range.end - playerItem.currentTime()).seconds } ?? 0 print("buffer: (buffer)") }) .disposed(by: disposeBag) Playerの監視
  77. 77. AVPlayer - TimeControlStatus @available(iOS 10.0, *) public enum AVPlayerTimeControlStatus : Int { case paused case waitingToPlayAtSpecifiedRate case playing } Playerの監視
  78. 78. AVPlayer - TimeControlStatus extension Reactive where Base: AVPlayer { var timeControlStatus: Observable<AVPlayerTimeControlStatus> { return base.rx.observe(AVPlayerTimeControlStatus.self, #keyPath(AVPlayer.timeControlStatus)) .filterNil() } } player.rx.timeControlStatus .subscribe(onNext: { timeControlStatus in // timeControlStatus }) .disposed(by: disposeBag) Playerの監視
  79. 79. AVPlayerItemAccessLog https://developer.apple.com/videos/play/wwdc2018/502/ Playerの監視
  80. 80. AVPlayerItemAccessLog playerItem.accessLog()?.events Playerの監視
  81. 81. AVPlayerItemAccessLog if let accessLog = videoPlayer.playerItem.accessLog()?.events.last { let log = """ uri: (accessLog.uri ?? "-")") serverAddress: (accessLog.serverAddress ?? "-")") mediaRequestsWWAN: (accessLog.mediaRequestsWWAN)") numberOfMediaRequests: (accessLog.numberOfMediaRequests)") playbackStartDate: (accessLog.playbackStartDate ?? Date.distantPast)") playbackSessionID: (accessLog.playbackSessionID ?? "-")") playbackStartOffset: (accessLog.playbackStartOffset)") playbackType: (accessLog.playbackType ?? "-")") startupTime: (accessLog.startupTime)") durationWatched: (accessLog.durationWatched)") numberOfDroppedVideoFrames: (accessLog.numberOfDroppedVideoFrames)") numberOfStalls: (accessLog.numberOfStalls)") observedMaxBitrate: (accessLog.observedMaxBitrate)") observedMinBitrate: (accessLog.observedMinBitrate)") switchBitrate: (accessLog.switchBitrate)”) … """ print(log) } Playerの監視
  82. 82. AVPlayerItemAccessLog https://developer.apple.com/videos/play/wwdc2018/502/ Playerの監視
  83. 83. AVPlayerItemAccessLog https://developer.apple.com/videos/play/wwdc2018/502/ Playerの監視
  84. 84. AVPlayerItemErrorLog https://developer.apple.com/videos/play/wwdc2018/502/ Playerの監視
  85. 85. AVPlayerItemErrorLog playerItem.errorLog()?.events Playerの監視
  86. 86. AVPlayerItemErrorLog if let errorLog = playerItem.errorLog()?.events.last { let log = """ uri: (errorLog.uri ?? "")") date: (errorLog.date ?? Date.distantPast)") serverAddress: (errorLog.serverAddress ?? "-")") playbackSessionID: (errorLog.playbackSessionID ?? “-")") errorStatusCode: (errorLog.errorStatusCode)") errorDomain: (errorLog.errorDomain)”) errorComment: (errorLog.errorComment ?? "-")") """ print(log) } Playerの監視
  87. 87. AVPlayerItemErrorLog https://developer.apple.com/videos/play/wwdc2018/502/ Playerの監視
  88. 88. Metrics https://developer.apple.com/videos/play/wwdc2018/502/ Playerの監視
  89. 89. Metrics Solution MUX CONVIVA Akamai Media Analytics YOUBORA etc.. Playerの監視
  90. 90. まとめ
  91. 91. まとめ 動画配信の技術を知る 最適な動画再生 × UXを追求する ユーザーの視聴体験を追求する
  92. 92. Thank you

×