[Xcode/Swift] AVFoundation完全理解への道⑤ (内部処理雑学編)

この記事で学ぶこと:

AVFoundation内部処理雑学編 (実装で完璧に知っておかなくてもいいけど知っておくとGood的な)

①を読んでなくても問題ないですが、概念が曖昧というか方は①から読まれるのをおすすめします。👇

[Xcode/Swift] AVFoundation完全理解への道①

AVFoundation内部処理雑学編

1.1 m3u8からの.ts取得フロー

AVPlayerでHLS再生(.m3u8)する流れ
├─ AVPlayerにAVPlayerItemをセット
│   └─ AVPlayerItemがAVURLAssetを参照
│       └─ AVURLAssetが.m3u8ファイル(Master Playlist)を読み込む
│           ├─ #EXT-X-STREAM-INF: 各画質(m3u8)のURLを取得
│           └─ ネット速度に応じた Variant Playlist を選択(ABR)
│               └─ 選択された m3u8 から .ts チャンクの一覧を取得
│                   ├─ .ts(MPEG-TS)ファイルを順次ダウンロード
│                   └─ 再生バッファに投入 → AVPlayerでデコード・再生

ポイント:

項目内容覚えるべきポイント
.m3u8HLSのプレイリスト(Master or Variant).ts の目次みたいなもん
AVAssetURLから .m3u8 を読み込み、AVPlayerItemに渡す情報取得や準備に使われる裏方
ABRAdaptive Bitrate Streaming、自動で画質選択プレイヤーが回線に応じて .m3u8 を切り替える
.ts チャンク3〜6秒程度の小さな動画ファイル順次ダウンロード・再生される
再生準備.tsがある程度読み込まれると再生可能にAVPlayerItem.status == .readyToPlay で判定

サンプル:

import SwiftUI
import AVKit

struct HLSPlayerView: View {
    private let url = URL(string: "https://bitdash-a.akamaihd.net/content/sintel/hls/playlist.m3u8")!
    private let player = AVPlayer()

    var body: some View {
        VideoPlayer(player: player)
            .onAppear {
                let item = AVPlayerItem(url: url)
                player.replaceCurrentItem(with: item)
                player.play()
            }
            .onDisappear {
                player.pause()
            }
            .navigationTitle("🎭 m3u8再生")
            .navigationBarTitleDisplayMode(.inline)
    }
}

1.2 再生中にネットが切れた時の内部処理

AVPlayer 再生中にネット切断 → 自動停止の流れ
├─ .ts セグメントの取得に失敗
│   └─ AVPlayerItemの isPlaybackBufferEmpty = true になる
│       ├─ 再生一時停止(rate = 0)
│       └─ KVOで状態検知 → ローディングUIなど対応可能
├─ ネット回復後
│   └─ バッファが再び溜まると isPlaybackLikelyToKeepUp = true
│       └─ AVPlayerが自動再開(または明示的に再開)

ポイント:

項目内容覚えるべきポイント
バッファ切れ.ts が取得できず再生ストップisPlaybackBufferEmpty == true
自動停止AVPlayerが rate = 0 に落ちるユーザー操作じゃなくても止まる
バッファ回復ネット回復後に .ts が再取得されるisPlaybackLikelyToKeepUp == true で再生再開可能
KVO監視状態をリアルタイムで監視UIでローディング表示する条件になる
手動再開プレイヤーが勝手に再生しないこともあるplayer.play() で明示的に再開しよう

サンプル:

import SwiftUI
import AVKit
import Combine

final class BufferMonitor: ObservableObject {
    @Published var isBuffering = false
    private var playerItemObservation: NSKeyValueObservation?

    func observe(item: AVPlayerItem) {
        playerItemObservation = item.observe(\.isPlaybackBufferEmpty, options: [.new]) { [weak self] item, _ in
            DispatchQueue.main.async {
                self?.isBuffering = item.isPlaybackBufferEmpty
            }
        }
    }
}

struct BufferingPlayerView: View {
    private let url = URL(string: "https://bitdash-a.akamaihd.net/content/sintel/hls/playlist.m3u8")!
    @StateObject private var bufferMonitor = BufferMonitor()
    private let player = AVPlayer()

    var body: some View {
        ZStack {
            VideoPlayer(player: player)
                .onAppear {
                    let item = AVPlayerItem(url: url)
                    bufferMonitor.observe(item: item)
                    player.replaceCurrentItem(with: item)
                    player.play()
                }
            if bufferMonitor.isBuffering {
                VStack {
                    ProgressView("Buffering...😵‍💫")
                }
                .frame(maxWidth: .infinity, maxHeight: .infinity)
                .background(.black.opacity(0.5))
            }
        }
        .navigationTitle("📶 バッファ物語")
        .navigationBarTitleDisplayMode(.inline)
    }
}

1.3 シークとサムネイルの裏側(YouTube風)

AVPlayerでseek(to:)した時の内部処理
├─ 指定されたCMTimeに最も近い.tsを探す
│   └─ Iフレーム(キーフレーム)までジャンプ
│       └─ シークは数秒ズレることもある(ピンポイント不可)
│
└─ サムネイル取得の流れ(AVAssetImageGenerator)
    ├─ AVAssetを元にイメージジェネレータ生成
    ├─ 指定時間のフレームを探す(キーフレーム基準)
    └─ CGImage生成 → UIImage化して表示

ポイント:

項目内容覚えるべきポイント
シーク処理Iフレーム単位でしか移動できない数秒ズレるのは仕様
CMTime指定秒数からCMTimeを生成してシークpreferredTimescale = 600 が安定
tolerance設定精度を高めるための範囲指定.zero 指定でピンポイント(ただし成功率下がる)
AVAssetImageGenerator任意時間のフレーム画像を抽出ローカル動画に向いてる(HLSは不安定)
よくある失敗duration未読み込み、HLS、範囲外時間loadValuesAsynchronously + durationチェックで回避

サンプル:

import SwiftUI
import AVFoundation

struct ThumbnailSeekerView: View {
    let asset = AVAsset(url: URL(string: "https://demo.unified-streaming.com/k8s/features/stable/video/tears-of-steel/tears-of-steel.ism/.m3u8")!)
    @State private var image: UIImage?

    var body: some View {
        VStack(spacing: 20) {
            if let img = image {
                Image(uiImage: img)
                    .resizable()
                    .scaledToFit()
                    .frame(height: 200)
            } else {
                ProgressView("Loading Thumbnail...")
            }

            Button("📸 30秒のサムネを取得") {
                generateThumbnail(at: 30)
            }
        }
        .padding()
        .navigationTitle("🔍 シーク&サムネ")
        .navigationBarTitleDisplayMode(.inline)
    }

    func generateThumbnail(at seconds: Double) {
        let generator = AVAssetImageGenerator(asset: asset)
        generator.appliesPreferredTrackTransform = true
        let time = CMTime(seconds: seconds, preferredTimescale: 600)

        DispatchQueue.global().async {
            do {
                let cgImage = try generator.copyCGImage(at: time, actualTime: nil)
                DispatchQueue.main.async {
                    self.image = UIImage(cgImage: cgImage)
                }
            } catch {
                print("❌ Thumbnail error:", error)
            }
        }
    }
}

次回: プロジェクトで使えるTips集

[Xcode/Swift] AVFoundation完全理解への道⑥ (実装Tips集)