跳到主要內容
Flow

播放引擎

執行緒與分級、核心協定、解碼策略、zero-copy、時鐘與 A/V 同步、狀態機與 Seek、Presenter。總覽見 architecture.md。

執行緒模型

執行緒由 FlowEngine 建立與擁有;模組方法只在下表指定的執行緒被呼叫。

執行緒(名稱)QoS/優先權職責
Demux(Flow.Demux)userInitiated呼叫 Demuxer.readPacket,寫入 PacketQueue
Video decode(Flow.VideoDecode)userInitiated呼叫 VideoDecoder.decode;VT 等非同步解碼器由其 callback 寫入 FrameSink
Audio decode(Flow.AudioDecode)userInteractive解碼並重取樣到輸出格式,經 PCMSink 寫入 PCMBuffer
Audio render(Core Audio IO 執行緒)即時AudioOutput 向核心的 AudioRenderSource 拉樣本;核心讀 PCMBuffer、套用音量並更新時鐘,禁止上鎖與配置記憶體
Render(Flow.Render)userInteractive,CAMetalDisplayLink選幀、呼叫 DisplayPipeline.render

資料平面元件之間以核心提供的單一生產者/單一消費者 lock-free ring buffer 溝通。跨平面不用事件串流:資料平面把各世代的進度(首幀入列、解碼結束、已顯示、已播完)寫入 PlaybackShared 的原子欄位,控制平面的 monitor task 只在載入、seek 或播放中輪詢(MediaPipeline.progress() 一次讀出當前世代的進度);致命錯誤、解碼器重設與音訊輸出的格式變更以 Task { @MainActor } 回報控制平面(只在 PlayerSession.load 傳給 PipelineRequest.onEvent 的 closure 建立;各元件出錯後停止處理,每次只回報一次;重設後該世代不再解碼),顯示目標變化經 PresenterBridge 回呼;統計為原子計數器(PlaybackStatistics)。

控制平面在 main actor,各元件單一擁有者:

元件職責
PlayerSessionCommand/Property 對應到狀態機、load 的序列化、錯誤處理與 fallback 政策
MediaPipeline一個媒體的資料平面(元件、執行緒、佇列、時鐘);提供 seek、播放與暫停、速率與音量、進度快照
DisplayOutput、DisplayBindingRender 執行緒、presenter 與各媒體的 RenderLoop、Display Graph(見渲染)
RestartTracker依進度快照與時間判定起播(playbackRestart)與 EOF

批次喚醒:佇列到高水位後,生產端睡到消費端把它降到低水位,才一次補回高水位(EngineConfiguration 的 videoFrameLowWater、packetLowWater、audioLowWater)。各執行緒與 VTDecoderXPCService 因此每批醒一次,而非每幀或每個 packet 一次。

  • 佇列決定何時喚醒誰:PacketQueue、FrameQueue、PCMBuffer 持有低水位與生產端的 Wakeup,取出後剩餘量 ≤ 低水位時喚醒生產端;PacketQueue 另持有解碼執行緒的 Wakeup,push 使 ring 由空轉為非空時喚醒它。Render 與 Audio render 執行緒由 display link 與音訊回呼驅動,不需喚醒。
  • 生產端只決定要不要補:遲滯由 Refill 實作,只由生產端執行緒讀寫。
  • PlayerSession 建立時以 EngineConfiguration.violations 驗證水位;只有 MediaPipeline 把它們交給佇列。

執行緒分級

資料平面一律跑在 Thread 或系統執行緒(Core Audio IO、display link),彼此以 SPSC ring buffer 溝通,不用 Swift Concurrency 與 GCD(ADR 6)。限制依執行緒分級;規則與量測衝突時以量測為準並更新本節。

分級執行緒理由
硬即時Audio render錯過期限即爆音
軟即時Render每個 vsync 預算固定,錯過即丟幀
吞吐Demux、Video decode、Audio decode佇列深度吸收延遲;FFmpeg、VideoToolbox 每次呼叫內部都會配置、上鎖與阻塞
行為硬即時軟即時吞吐
await、Task {}、GCD禁止禁止禁止;例外:致命錯誤與解碼器重設的回報(見執行緒模型)
Flow 程式碼上鎖禁止禁止禁止
Flow 程式碼配置記憶體禁止禁止避免;能預先配置的都預先配置
內部會配置或上鎖的函式庫、系統 API禁止允許(Metal、CoreVideo)允許(FFmpeg、VideoToolbox、CoreMedia)
阻塞(I/O、IPC、等待)禁止禁止,含等 GPU 完成與 nextDrawable允許,但控制平面須能中斷(如 AVIOInterruptCB)
Log 與 signpost禁止,含 signpostLogger、FlowSignpost.begin/endLogger、FlowSignpost.begin/end
釋放物件的最後一個參照禁止(觸發 deinit/free)C 函式庫物件禁止允許
  • 規則適用於生命週期的處理階段(readPacket、decode、render 等);Flow 擁有的物件在建立時(Factory.make 或 init)配置。
  • 硬即時、軟即時執行緒不得釋放的物件,經 ring buffer 送回擁有者執行緒釋放。
  • Callback 依所在執行緒分級,且一律不得阻塞:VideoToolbox output callback 阻塞會卡住解碼器。
  • 各執行緒的細則見解碼策略、Render 執行緒、Audio render、C 函式庫。

常見違規(硬即時、軟即時禁止,吞吐避免):

寫法原因改用
Mutex、NSLock、os_unfair_lock、切到 @MainActor優先權反轉Atomic + SPSC ring buffer
建立 class 實例、捕捉變數的 escaping closure堆積配置在 Factory.make 或 init 預先配置並重用
Array/Data/String 成長、字串插值、寫入共享 Array配置或 copy-on-write 複製預先配置的 UnsafeMutableBufferPointer
超過 3 個 word 的值存入 any Pexistential box 配置具體型別或泛型
以 throw 表達 EAGAIN、EOF 等正常狀態配置 error 實例回傳值;只對錯誤 throw
print、String(format:)配置與 I/OLogger、FlowSignpost

驗證(Instruments):

  • Allocations 依執行緒篩選:穩態播放時 Audio render 執行緒完全不配置,Render 執行緒沒有來自 Flow 程式碼的配置;吞吐執行緒每個 packet 的配置次數對照 baseline 保持穩定(自動量測見記憶體)。
  • System Trace:Audio render callback 無上鎖與阻塞,Render 執行緒無阻塞與優先權反轉;吞吐執行緒的阻塞只來自 I/O 或函式庫,且可中斷。

SPSC ring buffer

SPSCRing(FlowModuleAPI):

  • 容量在 init 固定為 2 的冪,並預先配置儲存空間。
  • 每個 ring 恰有一個生產者執行緒與一個消費者執行緒。
  • 生產者寫入元素後以 .releasing store tail,消費者以 .acquiring load tail;head 反之。
  • Seek 後由消費端依 generation 丟棄過期項目,不在鎖下清空(見狀態機與 Seek)。

核心協定

定義於 FlowModuleAPI;實作以 factory 註冊,由 FlowEngine 選擇與建立。

public protocol MediaSource: Sendable {
    var url: URL { get }
    var isSeekable: Bool { get }
    var length: Int64? { get }
    func read(into buffer: UnsafeMutableRawBufferPointer, at offset: Int64) throws -> Int
}

public protocol DemuxerFactory: Sendable {
    var id: String { get }
    func probe(_ source: any MediaSource) -> ProbeScore   // .unsupported 表示不支援
    /// `opener` 供需另開 URL 的 demuxer 使用
    func make(_ source: any MediaSource, opener: any MediaOpener) throws -> any Demuxer
}

/// 核心實作;依 URL scheme 選用已註冊的 `MediaSourceFactory`
public protocol MediaOpener: Sendable {
    func open(_ url: URL) throws -> any MediaSource
}

public protocol Demuxer: AnyObject {
    var tracks: [TrackInfo] { get }
    /// 第一次 readPacket 前呼叫一次;其他軌的 packet 可不讀
    func setEnabledTracks(_ indices: Set<Int>)
    func readPacket() throws -> Packet?            // nil = EOF
    func seek(to time: MediaTime, mode: SeekMode) throws
}

public protocol VideoDecoderFactory: Sendable {
    var id: String { get }
    func support(for format: VideoFormat) -> DecoderSupport  // .hardware / .software / .none
    /// 解出的幀寫入 `sink`;佇列由核心擁有
    func make(_ format: VideoFormat, sink: any FrameSink) throws -> any VideoDecoder
}

public protocol VideoDecoder: AnyObject {
    /// 每幀的像素格式;make 後固定,讓 Display Graph 在首幀前編譯
    var outputFormat: VideoPixelFormat { get }
    /// 非同步解碼器已收下、尚未產出的 packet 數;核心計入佇列高水位(同步解碼器為 0)
    var framesInFlight: Int { get }
    func decode(_ packet: Packet) throws
    /// EOF 時送出內部剩餘幀(如重排序中的 B 幀)
    func drain() throws
    func flush()
}

/// 核心實作,不阻塞;背壓由 `FlowEngine` 暫停呼叫 `decode` 達成。不持有解碼器,解碼器可強參照 sink
public protocol FrameSink: AnyObject, Sendable {
    /// Video decode 執行緒或解碼器 callback 呼叫,一次一個;依 PTS 遞增寫入
    func push(_ frame: VideoFrame)
    /// 解碼器 callback 中 framesInFlight 減少但未 push(丟棄、失敗或暫留的幀)時呼叫,讓核心恢復解碼;多呼叫無害
    func framesInFlightDidDecrease()
}

// CVPixelBuffer 非 Sendable;建立後唯讀故安全。CoreVideo 標註 Sendable 後移除 @unchecked
public struct VideoFrame: @unchecked Sendable {
    public let pixelBuffer: CVPixelBuffer   // 一律 IOSurface-backed
    public let pts: MediaTime
    public let duration: MediaTime
    public let color: ColorInfo             // primaries / transfer / matrix / range / chroma location
    public let geometry: FrameGeometry      // 旋轉(90° 倍數)、鏡像、像素長寬比
    public let generation: UInt32           // seek 世代,用來丟棄過期幀
}

MediaSource、AudioDecoder、AudioOutput、Presenter 同樣採 factory + 實例協定;AudioDecoder 寫入核心的 PCMSink,規則同 FrameSink。Presenter 與 DisplayRuntime 由核心成對選擇(見 Runtime 介面)。

  • 編碼:CodecID 是 Flow 自有的固定常數,不沿用任何函式庫的名稱;沒有對應常數的編碼為 .unknown,沒有解碼器支援。模組各自對應函式庫的識別碼(FlowFFmpeg 的 demuxer 與音訊解碼器共用同一張 AVCodecID 對應表)。
  • 色度格式:VideoFormat.chromaFormat(ChromaFormat)如實描述來源,可表達單色、4:1:1、4:4:0;讀不到或非 Y'CbCr(RGB、調色盤)時為 nil。chromaBitDepth 另記 chroma 位元深度(H.264/HEVC 可與 luma 不同),單色時為 nil。兩者決定幀排列,不設預設值;依規範推得的值是事實(如 H.264 非 High profile 為 4:2:0 8-bit,§7.4.2.1.1)。幀排列另以 ChromaSubsampling(420/422/444)描述,ChromaSubsampling.init?(ChromaFormat) 只對能排成雙平面的來源成功(ADR 20)。
  • Packet:bytes 由 owner(Sendable)保活;解碼器可持有 owner 到 decode 之後,在任何執行緒釋放(VideoToolbox 在自己的執行緒)。padding 為 bytes 之後可讀的零位元組數。容器的修剪由 demuxer 對應到契約欄位,解碼器照做:isDiscardable 只解碼供參考、不輸出;trim 為音訊開頭與結尾要丟的樣本數(如 AAC priming)。解碼器的輸出只依契約欄位,不依 owner 的型別(見 C 函式庫)。
  • 顯示幾何:FrameGeometry 由 demuxer 自容器(MP4 display matrix)或碼流(SAR)取得,存於軌道層級的 VideoFormat.geometry,解碼器複製到每幀;像素保持儲存方向,由 Display Graph 的 scale 依 VideoFormat.geometry 套用(見內建步驟)。

生命週期契約:

階段方法執行緒規則
建立Factory.make控制平面:背景 task(MediaPipeline.build,影片解碼器與音訊元件並行;解碼失敗後換用的解碼器亦在背景建立);PresenterFactory.make、DisplayRuntime.connect 在 main actor同步,可阻塞、配置記憶體、拋錯;失敗時 FlowEngine 換下一個實作
處理readPacket、decode、render 等資料平面(見執行緒模型)不得 await;上鎖、配置記憶體、阻塞依執行緒分級限制
Flushflush、seek該元件的資料平面執行緒;AudioOutput.flush 在控制平面只丟棄內部狀態,不釋放資源
釋放deinit控制平面;DisplayPipeline 在 Render 執行緒換新或解除綁定時釋放FlowEngine 保證在最後一次處理之後;DisplayPipeline 的 deinit 不得等 GPU;DisplayConnection 在其所有 pipeline 之後於控制平面釋放,可等 GPU

輸出契約:所有解碼器輸出 IOSurface-backed CVPixelBuffer 並填妥 ColorInfo,不做色彩轉換;YUV→RGB 由 Display Graph 掌控以保證品質一致。色彩以 ColorInfo 為準,CVPixelBuffer 的色彩 attachment 不屬契約、可能不完整(VT 為省每幀建立 CGColorSpace 的成本,只傳 BT.709 的轉移函數);需要 attachment 的輸出依 ColorInfo 自行設定。輸出須依 PTS 遞增,重排序由解碼器模組負責(如 VT:HEVC 即使開啟 temporal processing 仍依解碼順序輸出,由 FrameReorder 依 PTS 重排)。packet 沒有 duration 時由解碼器補上(如 VT 沿用前一幀的,與串流同 timescale、跟得上 VFR;首幀用 VideoFormat.frameRate.frameDuration)。

解碼策略

  1. FlowEngine 依 VideoFormat(codec、profile、bit depth、色度格式、解析度)詢問所有已註冊 factory 的 support():.hardware 優先於 .software,同級依 App 組裝順序。
  2. 建立或解碼發生致命錯誤時,FlowEngine 改用下一個 factory,並從播放位置以 .exact seek 重新解碼(從前一關鍵幀解到目標);新影片解碼器的 outputFormat 須與原本相同。解碼中換用時在背景建立,依當下註冊的 factory 並略過已試過的。全部失敗時列出各失敗原因並進入 error;音訊例外:有影片時只停用音訊,影片依 host time 單獨播放(同播放規則)。
    • 音訊與影片同樣換 factory,不重試同一解碼器:解碼器只對無法繼續的錯誤拋錯(損壞的 packet 自行略過)。停用音訊後 Audio decode 執行緒仍消耗 packet,各世代的音訊立即結束,避免 Demux 卡在滿的 ring。
    • 例外:影片解碼器可繼續但失去參考幀時(如 VideoToolbox 在睡眠、換 GPU 後重建 session)拋 VideoDecoderError.resetRequired,由核心決定之後的處理與丟棄:沿用同一解碼器,不再送該世代的 packet,從播放位置以 .exact seek(新世代先 flush)。在重設開始的世代出第一幀前再次要求重設,視為致命錯誤。
  3. 實作細節屬各模組,例如 FlowVideoToolbox 依內容選輸出格式(8-bit → 420YpCbCr8BiPlanar;10-bit/HDR → 420YpCbCr10BiPlanar;VideoRange/FullRange 依來源 range)。單色例外:原生輸出為單平面的 L008/L010,Display Graph 不支援,故要求同位元深度的 4:2:0,由 VideoToolbox 在硬體填入中性色度(M4 Max 實測)。

解碼器呼叫:

  • kVTDecodeFrame_EnableAsynchronousDecompression 不會讓 VTDecompressionSessionDecodeFrame 不阻塞,只在 Video decode 執行緒呼叫。
  • 同一 session 的 VideoToolbox output callback 在同一執行緒依序執行(實測),只由 callback 存取的狀態(如 FrameReorder 的重排序緩衝)不需上鎖。
  • libavcodec 可能阻塞等待 frame threading 的 worker,只在解碼執行緒呼叫。

Zero-copy

像素從解碼器輸出到顯示全程留在 IOSurface-backed CVPixelBuffer/MTLTexture;計數器與檢查見 benchmark.md。

  • CVPixelBufferPool 與 VideoToolbox 輸出屬性一律設 kCVPixelBufferIOSurfacePropertiesKey: [:] 與 kCVPixelBufferMetalCompatibilityKey: true。
  • 選 VideoToolbox 原生輸出格式(見解碼策略),避免 VideoToolbox 內部轉換;單色例外。
  • 送給 VideoToolbox 的 packet 以 CMBlockBufferCreateWithMemoryBlock 搭配 CMBlockBufferCustomBlockSource 直接包住 packet 記憶體,於 FreeBlock 歸還,不複製;送給 libavcodec 的,FlowFFmpeg 自己的 packet 直接借出其 AVBufferRef(不配置),其他以 av_buffer_create 包住並持有 owner,padding 不足 64 bytes 時由 libavcodec 複製。
  • CVPixelBuffer 以 IOSurface 直接建立 MTLTexture(見 VideoDisplayPipeline.render)。
  • CVPixelBufferLockBaseAddress 只用於 CPU 寫入端(軟解);GPU 處理的像素不在 CPU 讀取。
  • 不以 memcpy、Data(bytes:)、Array(buffer) 複製像素,不在 CPU 轉換格式或色彩(sws_scale、vImage)。

時鐘與 A/V 同步

時鐘與同步策略屬 FlowEngine;AudioOutput 與 Presenter 只回報時間戳。

時鐘

  • PlaybackClock 為錨點映射 (hostTime, mediaTime, rate),以原子快照發布,其他執行緒無鎖讀取;由 Audio render 執行緒依音訊時間更新。
  • 音訊時間=AudioOutput 回報的 (sample time, host time) 扣除輸出延遲(裝置、串流、safety offset、緩衝、time-stretch);裝置變更時重新查詢延遲。無音軌時以 host time 推進。
  • 音訊時間與時鐘的誤差小於 50 ms(hardResyncThreshold)時以 ≤ 0.5% 的速率微調收斂;超過時直接重新錨定,可向前或向後跳。播放中只差 50 ms 內的倒退不重新錨定,維持連續以免重複幀。
  • 音訊不連續(換裝置)或 underrun 後重新錨定。
  • AudioOutput 停止並重啟後,回報的 host time 須與已消耗的樣本一致;核心另保證「聽到的時間」不早於現在,防止輸出的 host time 停住時時鐘倒退。
  • 核心經 HostClock 協定取得時間與等待:期限一律為 HostTime(monitor 的輪詢、Render 執行緒備援 timer),只在設定 CFRunLoopTimer 時換算。測試注入虛擬時鐘(ManualHostClock),等待只隨測試推進時間。

同步策略:音訊時鐘主導,視訊依時鐘丟/重複幀,音訊不調整。

刷新率:節奏屬核心。FramePacing 依 VideoFormat.frameRate(FrameRate 為有理數,如 24000/1001)與顯示目標的 refreshRateRange 算出 FrameRateRange(最小、最大、偏好):最高刷新率在 1% 內為幀率的整數倍時偏好幀率,每幀得到相同次數的 refresh、Render 執行緒每幀只醒一次(ProMotion/VRR,如 24p → 120Hz 的 5:5 pacing、30p 在 60Hz);否則為整個範圍、偏好最高刷新率。核心以 Presenter.setFrameRateRange 交出,presenter 原樣套用(如 CAMetalDisplayLink.preferredFrameRateRange);refreshRateRange 變化(如換螢幕)時重新計算,結果不同才再交出,不重建 pipeline。

選幀(Render 執行緒,每 vsync)

  1. 以 display link 的 targetPresentationTimestamp 加上量測到的呈現延遲(PresentationLatencyEstimator:實際上屏時間 − targetPresentationTimestamp 的指數平均),換算該次繪製實際顯示時的媒體時間。
  2. FrameSelector 選 pts ≤ t 的最新幀,被略過的幀計為丟幀;邊界加遲滯(refresh interval 的 1/4)避免 cadence 抖動。
  3. 幀或 plan 有變時交由 DisplayPipeline 繪製;Presenter 回報實際顯示時間,用來校正呈現延遲並統計延遲幀。
  4. vsync 停住(如視窗被遮住)時,Render 執行緒以備援 timer 依時鐘消耗到期的幀,避免解碼卡在高水位。

起播、Seek、暫停、倍速

  • 起播與 seek:新 generation 的首個音訊樣本與視訊幀都就緒才啟動時鐘;音訊裁掉目標時間前的樣本,視訊先顯示首幀。
  • 暫停時 rate = 0 並停止 display link,只在畫面需更新(seek、顯示目標或參數變更)時繪製單幀。playback.rate 改變時重新錨定,音訊以保持音高的 time-stretch 播放。
  • EOF:音訊先結束改以 host time 推進;視訊結束時停在最後一幀。

狀態機與 Seek

idle → loading → ready → playing ⇄ paused
                 └───────────────→ paused
playing → ended
{ready, playing, paused, ended, seeking} → seeking → {ready, playing, paused}
任何狀態 → loading(load)、idle(stop);idle 以外 → error(fail)
  • PlaybackStateMachine 判定轉移;PlayerSession 先檢查狀態再轉移,不合法的轉移視為 bug(assertionFailure)。狀態(PlaybackState,定義於 FlowPlayerAPI)每次轉移後以 playback.state 公開;失敗原因轉為 PlayerError(見錯誤)。shutdown() 後取消註冊 Command,不再建立 pipeline 與執行緒。
  • Load:已開啟媒體時先在背景開啟來源與 demuxer(MediaPipeline.open),成功後才卸載舊媒體並轉為 loading,開不了則維持原狀態(見 load);未開啟媒體時由 MediaPipeline.build 一次完成開啟與建立,中途不回到 main actor(PERF-02)。
  • Seek(MediaPipeline.seek)先寫入目標,再以 releasing 遞增 generation、以 clock.hold 停在目標、經 DemuxWorker.requestSeek 請 Demux 執行緒呼叫 Demuxer.seek、喚醒解碼執行緒並呼叫 AudioOutput.flush,不需要同步等待;各 ring 由消費端丟棄舊世代的幀與樣本(控制平面不清空 SPSC ring,以維持單一生產者、單一消費者),解碼器的 flush 在其資料平面執行緒呼叫。
  • Seek 與載入的完成條件(RestartTracker):新世代的首幀已渲染、視訊已解碼完畢,或首幀已入列且尚無 display pipeline 或已等滿 displayWaitLimit(display link 被節流時);需要播放時另等音訊開始。
  • 完成後的狀態依 playback.paused:未暫停為 playing;暫停時為 paused,seek 前為 ready 則仍為 ready。從 ended seek 也依此規則。
  • SeekMode:.keyframe(拖動時即時預覽)與 .exact(放開後精準定位,從前一關鍵幀解碼至目標)。
  • 相對 seek 以進行中 seek 的目標為基準(到 playbackRestart 為止),否則以目前位置;連按方向鍵因此累加(同 mpv)。目標一律 clamp 到 0 與長度之間。
  • Demuxer 拋錯(readPacket、seek)不當作 EOF:Demux 執行緒停止,控制平面以 demuxFailed 進入 error 並發出 endFile(.error);解碼器錯誤見解碼策略。

Presenter

Presenter 協定定義於核心,回報顯示目標(DisplayTarget:尺寸、可切換的刷新率範圍、螢幕 EDR headroom)與每幀實際顯示時間;畫進它的 GPU 資源屬與它配對的 DisplayRuntime(見 Runtime 介面)。DisplayTarget 只描述螢幕;drawable 格式與色彩由 DisplayPlan.Output 決定,核心在新 plan 的 pipeline 建好後以 Presenter.apply 交給 presenter 套用(見輸出色彩)。顯示目標變化時通知 FlowEngine:重新編譯 Display Graph,DisplayPlan 相同即沿用;刷新率不在 plan 中,只重新計算 FrameRateRange(見時鐘與 A/V 同步)。實作在 FlowMetal4:

  • MetalLayerPresenter:預設。CAMetalLayer 由 Render 執行緒上的 CAMetalDisplayLink 驅動;headroom 取自所在螢幕的 maximumPotentialExtendedDynamicRangeColorComponentValue。依 plan 設定 layer colorspace(來源色彩空間,如 PQ/HLG/BT.2020)與 drawable 格式;HDR 另設 EDRMetadata,螢幕有 headroom 時開啟 EDR,tone mapping 交給系統 compositor。
  • OffscreenPresenter:以虛擬 vsync 渲染到 IOSurface,不需視窗;以 @_spi(Benchmark) 開放,僅供 Benchmarks 自行註冊(見 benchmark.md)。