播放引擎
執行緒與分級、核心協定、解碼策略、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,各元件單一擁有者:
| 元件 | 職責 |
|---|---|
PlayerSession | Command/Property 對應到狀態機、load 的序列化、錯誤處理與 fallback 政策 |
MediaPipeline | 一個媒體的資料平面(元件、執行緒、佇列、時鐘);提供 seek、播放與暫停、速率與音量、進度快照 |
DisplayOutput、DisplayBinding | Render 執行緒、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 | 禁止,含 signpost | Logger、FlowSignpost.begin/end | Logger、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 P | existential box 配置 | 具體型別或泛型 |
| 以 throw 表達 EAGAIN、EOF 等正常狀態 | 配置 error 實例 | 回傳值;只對錯誤 throw |
print、String(format:) | 配置與 I/O | Logger、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 恰有一個生產者執行緒與一個消費者執行緒。
- 生產者寫入元素後以
.releasingstore tail,消費者以.acquiringload 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;上鎖、配置記憶體、阻塞依執行緒分級限制 |
| Flush | flush、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)。
解碼策略
FlowEngine依VideoFormat(codec、profile、bit depth、色度格式、解析度)詢問所有已註冊 factory 的support():.hardware優先於.software,同級依 App 組裝順序。- 建立或解碼發生致命錯誤時,
FlowEngine改用下一個 factory,並從播放位置以.exactseek 重新解碼(從前一關鍵幀解到目標);新影片解碼器的outputFormat須與原本相同。解碼中換用時在背景建立,依當下註冊的 factory 並略過已試過的。全部失敗時列出各失敗原因並進入error;音訊例外:有影片時只停用音訊,影片依 host time 單獨播放(同播放規則)。- 音訊與影片同樣換 factory,不重試同一解碼器:解碼器只對無法繼續的錯誤拋錯(損壞的 packet 自行略過)。停用音訊後 Audio decode 執行緒仍消耗 packet,各世代的音訊立即結束,避免 Demux 卡在滿的 ring。
- 例外:影片解碼器可繼續但失去參考幀時(如 VideoToolbox 在睡眠、換 GPU 後重建 session)拋
VideoDecoderError.resetRequired,由核心決定之後的處理與丟棄:沿用同一解碼器,不再送該世代的 packet,從播放位置以.exactseek(新世代先flush)。在重設開始的世代出第一幀前再次要求重設,視為致命錯誤。
- 實作細節屬各模組,例如
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)
- 以 display link 的
targetPresentationTimestamp加上量測到的呈現延遲(PresentationLatencyEstimator:實際上屏時間 −targetPresentationTimestamp的指數平均),換算該次繪製實際顯示時的媒體時間。 FrameSelector選pts ≤ t的最新幀,被略過的幀計為丟幀;邊界加遲滯(refresh interval 的 1/4)避免 cadence 抖動。- 幀或 plan 有變時交由
DisplayPipeline繪製;Presenter回報實際顯示時間,用來校正呈現延遲並統計延遲幀。 - 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。從endedseek 也依此規則。 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)。