# 播放引擎

URL: https://flow.lanstar.dev/docs/architecture/engine

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

## 執行緒模型

執行緒由 `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（見[渲染](/docs/architecture/rendering#執行緒與元件)） |
| `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](#spsc-ring-buffer) 溝通，不用 Swift Concurrency 與 GCD（[ADR 6](/docs/architecture#架構決策)）。限制依執行緒分級；規則與量測衝突時以量測為準並更新本節。

| 分級   | 執行緒                            | 理由                                                                 |
| ------ | --------------------------------- | -------------------------------------------------------------------- |
| 硬即時 | 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 執行緒](/docs/architecture/rendering#執行緒與元件)、[Audio render](/docs/architecture/audio#render)、[C 函式庫](/docs/architecture/packages#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 保持穩定（自動量測見[記憶體](/docs/benchmark#記憶體)）。
- 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](#狀態機與-seek)）。

## 核心協定

定義於 `FlowModuleAPI`；實作以 factory 註冊，由 `FlowEngine` 選擇與建立。

```swift
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 介面](/docs/architecture/rendering#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](/docs/architecture#架構決策)）。
- **Packet**：`bytes` 由 `owner`（`Sendable`）保活；解碼器可持有 `owner` 到 `decode` 之後，在任何執行緒釋放（VideoToolbox 在自己的執行緒）。`padding` 為 `bytes` 之後可讀的零位元組數。容器的修剪由 demuxer 對應到契約欄位，解碼器照做：`isDiscardable` 只解碼供參考、不輸出；`trim` 為音訊開頭與結尾要丟的樣本數（如 AAC priming）。解碼器的輸出只依契約欄位，不依 `owner` 的型別（見 [C 函式庫](/docs/architecture/packages#c-函式庫)）。
- **顯示幾何**：`FrameGeometry` 由 demuxer 自容器（MP4 display matrix）或碼流（SAR）取得，存於軌道層級的 `VideoFormat.geometry`，解碼器複製到每幀；像素保持儲存方向，由 Display Graph 的 `scale` 依 `VideoFormat.geometry` 套用（見[內建步驟](/docs/architecture/rendering#內建步驟)）。

**生命週期契約**：

| 階段  | 方法                                | 執行緒                                                 | 規則                                                   |
| ----- | ----------------------------------- | ------------------------------------------------------ | ------------------------------------------------------ |
| 建立  | `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`）。

## 解碼策略

1. `FlowEngine` 依 `VideoFormat`（codec、profile、bit depth、色度格式、解析度）詢問所有已註冊 factory 的 `support()`：`.hardware` 優先於 `.software`，同級依 App 組裝順序。
2. 建立或解碼發生致命錯誤時，`FlowEngine` 改用下一個 factory，並從播放位置以 `.exact` seek 重新解碼（從前一關鍵幀解到目標）；新影片解碼器的 `outputFormat` 須與原本相同。解碼中換用時在背景建立，依當下註冊的 factory 並略過已試過的。全部失敗時列出各失敗原因並進入 `error`；音訊例外：有影片時只停用音訊，影片依 host time 單獨播放（同[播放規則](/docs/codecs#播放規則)）。
    - 音訊與影片同樣換 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`](/docs/benchmark#zero-copy)。

- `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`](/docs/architecture/rendering#videodisplaypipelinerender)）。
- `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`（見[錯誤](/docs/architecture/player-api#錯誤)）。`shutdown()` 後取消註冊 Command，不再建立 pipeline 與執行緒。
- Load：已開啟媒體時先在背景開啟來源與 demuxer（`MediaPipeline.open`），成功後才卸載舊媒體並轉為 `loading`，開不了則維持原狀態（見 [`load`](/docs/architecture/player-api#commandproperty-系統)）；未開啟媒體時由 `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 介面](/docs/architecture/rendering#runtime-介面)）。`DisplayTarget` 只描述螢幕；drawable 格式與色彩由 `DisplayPlan.Output` 決定，核心在新 plan 的 pipeline 建好後以 `Presenter.apply` 交給 presenter 套用（見[輸出色彩](/docs/architecture/rendering#輸出色彩)）。顯示目標變化時通知 `FlowEngine`：重新編譯 Display Graph，`DisplayPlan` 相同即沿用；刷新率不在 plan 中，只重新計算 `FrameRateRange`（見[時鐘與 A/V 同步](#時鐘與-av-同步)）。實作在 `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`](/docs/benchmark#基礎設施)）。
