# 渲染

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

> Display Graph 的設計，以及從檔案讀取到畫面顯示的完整流程；以程式碼為準。總覽見 [`architecture.md`](/docs/architecture)。

## 概觀

Display Graph 每 vsync 最多執行一次，一進一出、不得阻塞、不依賴未來幀；由 `FlowGraph` 編譯成 `DisplayPlan`，由 `FlowMetal4` 執行。只含 `DisplayRuntime` 提供的內建步驟，融合為 1 個 compute pass（[ADR 16、17](/docs/architecture#架構決策)）。解碼器輸出直接進 `FrameQueue`。

```mermaid
flowchart LR
    Dec["VideoDecoder"] -->|"VideoFrame<br/>(IOSurface CVPixelBuffer)"| Sink["VideoFrameSink"]
    Sink --> FQ["FrameQueue"]
    FQ --> RL["RenderLoop<br/>(Render thread)"]
    RL -->|"render(frame, at:)"| DP["DisplayPipeline<br/>(Display Graph)"]
    DP --> Drawable["CAMetalDrawable"]
```

## 執行緒與元件

| 執行緒 | 元件 | 工作 |
| --- | --- | --- |
| Main | `PlayerSession`、`DisplayOutput`、`DisplayBinding`、`MetalVideoView`、`MetalLayerPresenter`（layer 設定） | 控制平面：建立 pipeline、編譯 Display Graph、更新 `DisplayTarget` |
| `Flow.Demux` | `DemuxWorker` + `FFmpegDemuxer` | 讀 packet 進 `PacketQueue` |
| `Flow.VideoDecode` | `VideoDecodeWorker` + `VTVideoDecoder` | 送 packet 給 VideoToolbox；EOF `drain` 時由 `FrameReorder.finish` 推入剩餘幀 |
| VideoToolbox callback | `FrameReorder` → `VideoFrameSink` | 依 PTS 排序後推進 `FrameQueue` |
| `Flow.Render` | `CAMetalDisplayLink` → `PresenterBridge` → `RenderLoop` → `VideoDisplayPipeline` → `FrameSubmitter` | 每個 vsync 選幀、編碼、提交 |
| GPU／Metal feedback | `Display.metal`（compute kernel `flow_display`）、`FrameSubmitter` 的 commit feedback、`PresentationLog` | 執行 pass；commit feedback 回報 GPU 時間（`Metal4Statistics`，每個 connection 一份），`PresentationLog` 回報實際顯示時間 |

`DisplayOutput` 擁有 Render 執行緒、presenter、與它配對的 runtime 及 `DisplayConnection`、`PresenterBridge`，與 `PlayerSession` 同壽命；`DisplayBinding` 每次載入建立，擁有該媒體的 `RenderLoop` 與 `DisplayPlan`，負責編譯與交出 `FrameRateRange`。

Render 執行緒為軟即時（見[執行緒分級](/docs/architecture/engine#執行緒分級)）：

- `CAMetalDisplayLink` 加入 Render 執行緒的 run loop，callback 才在 Render 執行緒執行。
- drawable 取自 `CAMetalDisplayLink.Update.drawable`；不呼叫 `nextDrawable`，它會阻塞到有空的 drawable（最長 1 秒）。
- drawable 數量由 `CAMetalDisplayLink` 決定，不受 `maximumDrawableCount` 與 `preferredFrameLatency` 影響：60 Hz 以下 4 個、120 Hz 5 個（M4 Max，macOS 27.2）。
- 不等 GPU 完成：在 GPU 上的幀以 slot 限制，滿了即 skip（見 [`VideoDisplayPipeline.render`](#videodisplaypipelinerender)）。

## Display Graph

### Runtime 介面

核心只定義 runtime 介面，不依賴任何 GPU API（[ADR 12](/docs/architecture#架構決策)）：

```swift
// FlowModuleAPI
/// 只提供身分
public protocol NodeRuntime: AnyObject, Sendable {
    var id: RuntimeID { get }
}

/// 能執行 Display Graph 並提供內建步驟
public protocol DisplayRuntime: NodeRuntime {
    /// 控制平面；能否畫進 presenter
    func accepts(_ presenter: any Presenter) -> Bool
    /// 控制平面，每次配對一次；結果持有畫進 presenter 所需的資源
    func connect(_ presenter: any Presenter) throws -> any DisplayConnection
}

/// runtime 與一個 presenter 的配對；核心在其所有 pipeline 之後、於控制平面釋放
public protocol DisplayConnection: AnyObject {
    /// 控制平面；plan 改變就重新呼叫，昂貴狀態（pipeline state）由 runtime 快取
    func makePipeline(_ plan: DisplayPlan) throws -> any DisplayPipeline
    /// 任何執行緒；此 connection 所有 pipeline 的提交、pass 數與 GPU 時間
    var statistics: PlayerStatistics.Display { get }
}

public protocol DisplayPipeline: AnyObject {
    /// Render 執行緒，每 vsync 最多一次；不得阻塞。幀與 plan 都沒變時核心不呼叫，畫面維持上一個 drawable
    func render(_ frame: VideoFrame, at time: HostTime)
}
```

|      | `FlowGraph`（核心）                                      | `DisplayRuntime`（模組）                              |
| ---- | -------------------------------------------------------- | ----------------------------------------------------- |
| 拓撲 | 依固定順序排列內建步驟、切分 pass                        | —                                                     |
| 規格 | 解析色彩、套用幾何、計算 letterbox                       | —                                                     |
| 資源 | 規劃 intermediate、估算每幀讀寫量（`DisplayPlan.cost`）  | 紋理快取、uniform ring、residency set（每個 connection 一份） |
| 執行 | —                                                        | 合併 pass、編碼與提交、pipeline cache、GPU 計時        |

presenter 與 runtime 由核心成對選擇（[ADR 22](/docs/architecture#架構決策)）：

- **配對**：`DisplayOutput.attach` 依序以 `PresenterFactory` 建立 presenter，交給第一個 `accepts` 它的 runtime `connect`；`connect` 失敗換下一個接受的 runtime，沒有 runtime 接受時換下一個 factory，全部失敗才丟 `allCandidatesFailed`。載入時沿用這一對；runtime 已不在 registry（模組停用）時重新配對。
- **分工**：presenter 只提供每個 vsync 的 destination（drawable）、套用 `DisplayPlan.Output` 並回報顯示時間；畫進它的 GPU 資源由 `DisplayConnection` 持有。runtime 取用 presenter 的具體介面（如 `FlowMetal4` 的 `MetalDisplaySurface`）屬模組內部。
- **統計**：runtime 在 Render 執行緒與 GPU 完成的 callback 以原子計數器記錄，不上鎖、不配置；核心只在 `PlayerAPI.statistics` 被讀取時於控制平面讀 `statistics`（見[統計](/docs/architecture/player-api#統計)）。
- **釋放**：pipeline 可強參照 connection。核心在 Render 執行緒釋放 pipeline（換新，或 `RenderLoop.detach`），之後才由 `DisplayBinding`、`DisplayOutput` 在控制平面釋放 connection，所以等 GPU 只發生在控制平面。

### 內建步驟

```mermaid
flowchart LR
    L[("luma plane")] --> Y2R
    C[("chroma plane")] --> CU
    CU["chromaUpsample<br/>bilinear · hardwareBilinear<br/>size = source"] --> Y2R["yuvToRGB<br/>matrix · range · bitDepth<br/>pointwise<br/>size = source"]
    Y2R --> SC["scale<br/>bilinear · hardwareBilinear<br/>foldsIntoInputSampling<br/>size = output"]
    SC --> T[("target")]
```

- **chroma 升取樣**：bilinear；luma 不經此步。4:4:4 時省略。`VideoPixelFormat` 為雙平面 Y'CbCr：4:2:0（`nv12`、`p010`）、4:2:2（`nv16`、`p210`）、4:4:4（`nv24`、`p410`），10-bit 放在 16-bit 樣本的高位。
- **YUV→RGB**：依 `ColorInfo` 的 matrix、range 與位元深度 dequantize 並轉成非線性 RGB。`yuvToRGB` 的 matrix 只有 BT.601（`smpte170m`）、BT.709、BT.2020 NCL，由 `DisplayGraph` 近似：BT.470BG、FCC 為 BT.601；BT.2020 CL、ICtCp 為 BT.2020 NCL；SMPTE 240M、YCgCo、identity（RGB）、SMPTE 2085、chroma-derived 為 BT.709。
- **輸出**：kernel 寫非線性 RGB，保留來源的色域與轉移函數，由系統 compositor 轉換（見[輸出色彩](#輸出色彩)）。
- **幾何**：`scale` 依 `VideoFormat.geometry`（編譯進 `DisplayPlan.geometry`）與 Property `video.rotate` 套用旋轉、鏡像與像素長寬比，並等比縮放置中於輸出（letterbox）；之前的步驟皆處理未旋轉的儲存畫面（同 mpv）。算法見[縮放](#縮放)。

### 輸出色彩

`DisplayGraph` 依來源色彩與 `DisplayTarget.headroom` 決定 `DisplayPlan.Output` 的 drawable 格式與 `OutputColor`；presenter 只套用，不讀來源。

| 來源 | `headroom` | drawable | `dynamicRange` | `toneMapping` |
| --- | --- | --- | --- | --- |
| SDR 8-bit | — | `bgra8Unorm` | `standard` | `none` |
| SDR 10-bit | — | `bgr10a2Unorm` | `standard` | `none` |
| HDR（PQ／HLG） | 1（SDR 螢幕） | `bgr10a2Unorm` | `standard` | `compositor` |
| HDR（PQ／HLG） | > 1 | `bgr10a2Unorm` | `high` | `compositor` |

- **色域與轉移函數**：沿用來源的 primaries 與 transfer，由 compositor 轉到螢幕色域，不多一個 pass。presenter 只標記下表的組合，其餘由 `DisplayGraph` 近似（見下）。
- **Tone mapping**：只由一方做。`compositor` 時 `OutputColor.metadata` 帶來源的靜態 HDR metadata，compositor 依當前 headroom、參考模式與省電狀態對應；`none` 時不帶。
- **EDR**：只在 HDR 來源且螢幕有 headroom 時開啟，因為 EDR 耗電。`headroom` 是螢幕可達的上限，不隨亮度變動；plan 只記 `dynamicRange`，所以在 EDR 螢幕間移動時 plan 不變、不重建 pipeline。
- **HDR metadata**：`VideoFormat.hdr`（mastering display、MaxCLL／MaxFALL）。`FlowFFmpeg` 先讀容器（MP4 `mdcv`／`clli`、Matroska），再讀 HEVC hvcC 內的 SEI；都沒有時用系統預設值。
- **套用**：核心在新 plan 的 pipeline 建好後呼叫 `Presenter.apply(plan.output)`；`MetalLayerPresenter` 設 `CAMetalLayer` 的 `colorspace`（如 `itur_2100_PQ`）、`pixelFormat`、`EDRMetadata`（HDR10 或 HLG）與 `wantsExtendedDynamicRangeContent`；`OffscreenPresenter` 只套用 drawable 格式。

presenter 能標記的組合：

| primaries | transfer |
| --- | --- |
| BT.709、BT.2020、Display P3（`smpte432`） | BT.709、sRGB、PQ、HLG |
| SMPTE C（`smpte170m`）、BT.470BG | BT.709 |

其餘由 `DisplayGraph` 近似到最接近的組合，近似表以 golden 鎖定（`Packages/Core/Tests/Kernel/FlowGraphTests/Golden/display-color.json`）：

- **transfer**：BT.601、BT.2020 與 BT.709 曲線相同；PQ、HLG、sRGB 以外一律為 BT.709。
- **primaries**：DCI-P3 為 Display P3；SMPTE 240M、BT.470M 為 SMPTE C；EBU 3213 為 BT.470BG；film、SMPTE 428 為 BT.709。
- **組合**：SMPTE C、BT.470BG 搭 PQ／HLG 時 primaries 改為 BT.709，搭 sRGB 時 transfer 改為 BT.709。

### 編譯

`DisplayBinding.makeDisplayPipeline` → `DisplayGraph.compile` → `DisplayConnection.makePipeline`。只在來源格式、輸出尺寸、螢幕 EDR headroom 或 `video.rotate` 變化時重新編譯；`DisplayPlan` 與上次相同時不重建 pipeline。

```mermaid
flowchart TD
    subgraph Input["DisplayGraphInput"]
        VF["VideoFormat<br/>size / color / hdr / geometry"]
        PF["VideoDecoder.outputFormat"]
        Rot["video.rotate"]
        Tgt["Presenter.target<br/>size / headroom"]
    end

    Input --> Compile["DisplayGraph.compile"]
    Compile -->|"throw（如 emptyOutput）<br/>try? → nil"| Nil["不建 pipeline<br/>沿用既有的（載入時則尚無）"]
    Compile --> Resolve["color.resolved<br/>DisplayColor（近似）<br/>geometry.rotated<br/>letterbox(displaySize, in: output)<br/>output(color, hdr, headroom)"]
    Compile --> Steps["LogicalStep[]"]
    Steps --> Split["PassSplitter.split"]
    Split --> Plan["DisplayPlan<br/>source / output / geometry<br/>passes / intermediates / writesOutput（固定 false） / cost"]
    Resolve --> Plan

    Plan -->|"與上次相同則略過"| Runtime["MetalDisplayConnection.makePipeline"]
    Runtime --> VDP["VideoDisplayPipeline.init"]
    VDP --> Program["DisplayProgram<br/>(不支援的 plan 丟 unsupportedPlan)"]
    VDP --> PSO["Metal4Context.displayPipeline(for:)<br/>快取的 compute PSO（與 drawable 格式無關）<br/>prewarm 未完成則同步編譯"]
    VDP -->|"成功後"| Apply["Presenter.apply(plan.output)<br/>layer colorspace / pixelFormat / EDR"]
    Apply -->|"renderThread: loop.setPipeline"| RL["RenderLoop"]
```

`DisplayProgram` 只接受：1 個 pass、輸入為 `luma`＋`chroma`、輸出為 `target`；`chromaUpsample` 限 bilinear，且取樣比例等於來源（4:4:4 時不得出現；其餘必須出現）；`yuvToRGB` 的 `bitDepth` 須等於來源、matrix 一對一對應 `DisplayMatrix`；`scale` 限 bilinear；`OutputColor` 須是 `LayerFormat` 一對一對應的組合（見[輸出色彩](#輸出色彩)）；runtime 不自行近似。其餘丟 `unsupportedPlan`，`makePipeline` 隨之失敗：載入時使 `load` 失敗，重新編譯時只記 log、保留舊 pipeline。

**重新編譯觸發點**：

```mermaid
flowchart LR
    Resize["視窗大小 / scale / 螢幕（含 EDR headroom）"] --> TC["PresenterClient.displayTargetDidChange"]
    TC -->|"PresenterBridge<br/>Task @MainActor"| Re["DisplayBinding.recompile"]
    VR["observe(.videoRotate)"] --> Re
    Load["load：pipeline = built 後同步呼叫<br/>（見建立期）"] --> Re
    Re --> Rate{"FramePacing 依 target.refreshRateRange<br/>算出的 FrameRateRange 與上次不同"}
    Rate -->|"是"| Pref["Flow.Render：setFrameRateRange"]
    Re --> Make["makeDisplayPipeline"]
    Make -->|"新 plan"| Set["setPipeline + requestRedraw"]
    Make -->|"plan 相同或 compile 失敗"| Skip["不重建"]
```

只有刷新率變化時 plan 相同，不重建 pipeline，`FrameRateRange` 有變才重設。視窗大小的完整路線見[縮放](#縮放)。

### Pass 切分

中間紋理的讀寫是主要成本（4K `rgba16Float` 每多一個 pass 約多讀寫 133 MB），故 `PassSplitter` 只在取樣前切開：

- 逐像素步驟接到當前 pass 的尾端，中間值留在暫存器。
- 遇到取樣步驟時，結束當前 pass 並寫出紋理，取樣步驟開新 pass。
- 硬體 bilinear 取樣（如 bilinear chroma 升取樣）視為逐像素，可放在 pass 開頭。
- `scale`（硬體 bilinear，可折疊）不切開，直接以輸出座標取樣來源平面（同 libplacebo）；先內插再做非線性步驟，屬可接受的近似。chroma 也是 bilinear 且沒有取樣步驟時，整條管線只有一個 pass。

```mermaid
flowchart TD
    S["下一個 step"] --> K{"sampling"}
    K -->|"pointwise"| J["加入當前 pass"]
    K -->|"hardwareBilinear"| H{"pass 為空<br/>或 (可折疊 且 尚無 sampled)"}
    H -->|"是"| J2["加入當前 pass<br/>samplesPassInput = true"]
    H -->|"否"| Cut1["切 pass 後加入"]
    K -->|"sampled<br/>(無內建步驟屬此類)"| Cut2["切 pass 後加入<br/>標記 hasSampled"]
```

切出的 pass：第一個讀 `luma`、`chroma`；之後讀 `texture(i-1)`；最後一個寫 `target`，其餘寫 `rgba16Float` intermediate。內建步驟全部併入同一 pass（4:4:4 沒有 `chromaUpsample`）：

```mermaid
flowchart LR
    subgraph P0["Pass 0 → target"]
        direction LR
        a["chromaUpsample"] --> b["yuvToRGB"] --> c["scale"]
    end
    luma[("luma")] --> P0
    chroma[("chroma")] --> P0
    P0 --> target[("drawable")]
```

**範例 plan**：`Packages/Core/Tests/Kernel/FlowGraphTests/Golden/nv12-down2-3x-r90.json`，1920×1080 NV12、旋轉 90°，輸出 720×1280 `bgra8Unorm`。

```mermaid
flowchart LR
    src["source<br/>1920×1080 nv12<br/>bt709 limited, chroma left"] --> p0["pass 0<br/>chromaUpsample → yuvToRGB → scale"]
    p0 --> out["target 720×1280 bgra8Unorm<br/>destination (0,0,720,1280)"]
    cost["cost<br/>passCount 1<br/>intermediateBytesPeak 0<br/>read 3,110,400 B<br/>write 3,686,400 B"]:::note

    classDef note fill:#f6f6f6,color:#555
```

### DisplayRuntime

`Metal4DisplayRuntime`（`FlowMetal4`）：

1. **配對**：接受 `FlowMetal4` 的 presenter。`connect` 建立 `MetalDisplayConnection`，持有該 presenter 的 `FrameSubmitter`（command queue、frame slot、residency set、uniform ring、`SurfaceTextureCache`），並把 presenter 的 destination residency set（`CAMetalLayer.residencySet`，或 offscreen 目標自建的）加入 queue；`MetalLayerPresenter` 在此取得 device。
2. **只用 compute**：所有 pass 皆為 compute kernel，不用 render pass（[ADR 17](/docs/architecture#架構決策)）。驅動依行程用過的 encoder 種類配置 GPU context（M4 Max：compute 160 MB、render 224 MB、兩者並用約 290 MB），單一種類最省且路徑單純。kernel 直接寫 drawable，所以 `framebufferOnly = false`；代價是 drawable 失去無損壓縮（IOSurface 由 `&BGA` 變為 `BGRA`），WindowServer 合成時多讀頻寬。blit 不能寫 `framebufferOnly` 的 drawable，公開 API 也建不出 compute 可寫的壓縮 IOSurface，故接受此代價。
3. **合併 pass**：只含內建步驟的 pass 為單一 kernel `flow_display`，以 function constant（matrix × range × bitDepth）特化為 12 組 pipeline；色度取樣只改變 uniforms，不另特化；可寫入任何 drawable 格式。
4. **提交**：一幀一個 command buffer（重複使用 command allocator + argument table），最多 3 幀 in-flight，以 `MTLSharedEvent` 控制；slot 用滿時略過該幀，不阻塞。
5. **Pipeline cache**：`Metal4Context.prewarm` 在模組啟用時於背景建立全部 12 組 `MTLComputePipelineState` 並快取於記憶體，換 plan 不編 shader；prewarm 未完成時，`displayPipeline(for:)` 在控制平面同步編譯該組。
6. **計時**：每個 command buffer 以 `MTL4CommitFeedback` 的 GPU 起訖時間量測，寫入 connection 的 `Metal4Statistics`，經 `DisplayConnection.statistics` 回報。

## 建立期

```mermaid
sequenceDiagram
    participant App as FlowMac (Main)
    participant S as PlayerSession (Main)
    participant D as DisplayOutput／DisplayBinding (Main)
    participant P as MetalLayerPresenter
    participant R as Flow.Render
    participant B as MediaPipeline.build (背景)

    App->>App: FlowApp.init：AppModel.activateModules → FlowPlayer（模組啟用，背景預熱 VideoToolbox、音訊）
    App->>App: AppModel.init：FlowPlayer.makeVideoView → MetalVideoView (CAMetalLayer)
    App->>S: attach(view)（視窗建立前，Task.immediate）
    S->>D: DisplayOutput.attach
    D->>R: RenderThread.start()
    D->>P: MetalLayerPresenterFactory.make<br/>layer: bgra8、framebufferOnly = false、3 drawables、BT.709
    D->>D: Metal4DisplayRuntime.accepts → connect<br/>MetalDisplayConnection：layer.device、FrameSubmitter
    App->>App: VideoSurface.makeNSView 把 view 放進視窗
    P->>P: view 進入視窗 → geometryDidChange → target.size
    App->>S: load(LoadRequest(url))（Task.immediate）
    S->>B: MediaSource → Demuxer → VTVideoDecoder ∥ AudioOutput、AudioDecoder<br/>(IOSurface + Metal-compatible CVPixelBuffer)
    B->>B: clock.hold(0)、start()：Demux／VideoDecode／AudioDecode threads
    S->>D: DisplayOutput.bind → DisplayBinding：RenderLoop<br/>makeDisplayPipeline → DisplayGraph.compile → DisplayConnection.makePipeline
    D->>P: makePipeline 成功後 Presenter.apply(plan.output)：colorspace、drawable pixelFormat、EDR
    D->>R: bridge.loop = loop、setPipeline、setFrameRateRange、activate → startVSync
    S->>D: pipeline、binding 設定後 recompileDisplay()
    S->>S: publish video.display-size<br/>= VideoFormat.geometry + video.rotate
    S-->>App: observe video.display-size → window.fit(video:)
    S-->>App: fileLoaded
    S->>S: playback.paused 為 false 時 play()
```

- attach 進行中開始的 `load` 先等它完成，以免在沒有 presenter 時載入而略過影像；App 不需自己排隊，只須在 load 之前開始 attach（`Task.immediate`）。
- `target.size` 為 0（尚未 layout）時 `compile` 丟 `emptyOutput`，`try?` 轉成 `nil`，先不建 pipeline，等第一次 layout 觸發重新編譯。
- `Presenter.apply` 由 `DisplayBinding` 在 `makePipeline` 成功後呼叫，plan 相同時不重建也不再套用；在 main thread 設定 layer，不改 `target`，所以不會再觸發重新編譯。
- `DisplayOutput.bind` 期間的 target 變更（如 layout）由 `PresenterBridge` 排入的 `recompileDisplay` 處理時，`binding` 尚未設定而略過；因此 `load` 設定 `binding` 後同步呼叫 `recompileDisplay()` 補上，plan 相同時不重建。
- 視窗尺寸取自 `video.display-size`，不經 `DisplayPlan`：target 尚未 layout、plan 為 `nil` 時也已知道影片尺寸。

## 每幀資料流

預設值見 `EngineConfiguration`：`FrameQueue` 16 格、`videoFrameHighWater` 5（低水位 2）、`PacketQueue` 每軌 1024 格、`packetReadahead` 64（低水位 32）、音訊高水位 0.5 s（低水位 0.25 s）。到高水位後等降到低水位才一次補滿（見[執行緒模型](/docs/architecture/engine#執行緒模型)）。

```mermaid
flowchart TD
    subgraph Demux["Flow.Demux"]
        D1["FFmpegDemuxer 讀 packet"] --> D2["PacketQueue (SPSC)<br/>補到 readahead (64)"]
    end
    subgraph Decode["Flow.VideoDecode"]
        V1["pop packet<br/>(FrameQueue 為空，或補充中且 queued + inFlight < highWater (5)；<br/>queued ≤ lowWater (2) 才開始補充)"] --> V2["VTDecompressionSessionDecodeFrame<br/>(非同步)"]
        V3["EOF：drain → FrameReorder.finish<br/>剩餘幀在此執行緒 push"]
    end
    subgraph VT["VideoToolbox callback"]
        C1["CVPixelBuffer (IOSurface)<br/>nv12 / p010"] --> C2["FrameReorder：依 PTS 排序"]
        C2 --> C3["VideoFrameSink.push<br/>濾掉舊 generation；exact seek 留 candidate"]
        C3 --> C4["FrameQueue.push (SPSC，16 格)"]
    end
    subgraph Render["Flow.Render（每個 vsync）"]
        R1["CAMetalDisplayLink.Update<br/>取得 drawable"] --> R2["PresenterBridge → RenderLoop.vsync<br/>t = clock(presentationTime + latency)"]
        R2 --> R3["FrameSelector.select<br/>pts ≤ t 的最新幀，margin = interval / 4"]
        R3 -->|"幀或 plan 有變"| R4["VideoDisplayPipeline.render"]
        R3 -->|"沒變"| R9["不畫，畫面維持"]
    end
    D2 --> V1
    V2 --> C1
    V3 --> C3
    C4 --> R3
    R3 -.->|"FrameQueue.pop／dropStale 後 count ≤ lowWater：喚醒 decode"| V1
    C2 -.->|"framesInFlightDidDecrease（count ≤ lowWater）、舊 generation 丟棄：喚醒 decode"| V1
    V1 -.->|"PacketQueue.pop 後 count ≤ packetLowWater (32)：喚醒 demux"| D1
    D2 -.->|"ring 由空轉非空：喚醒 decode"| V1
    R4 --> G["GPU：Display.metal"]
    G --> Comp["系統 compositor"]
    Comp -->|"presentedHandler"| L["PresentationLog → PresenterBridge.frameDidPresent<br/>（只在 presentedTime > 0）<br/>PlaybackStatistics.presentationLatency 指數平均（1/8）"]
    L -.->|"回饋到下一個 t"| R2
```

### `VideoDisplayPipeline.render`

每個 skip 都計入 `Metal4Statistics.recordSkip`：

1. `surface.currentDestination()`：這個 vsync 的 drawable；沒有則 skip。
2. `submitter.acquireSlot()`：最多 3 幀在 GPU 上；滿了就 skip，不阻塞。
3. `submitter.planes(for:slot:)`：`SurfaceTextureCache` 以 IOSurfaceID 查表，且同時檢查 `entry.surface === surface`，直接把 IOSurface 的 plane 包成 `MTLTexture`（8-bit：`r8`／`rg8`；10-bit：`r16`／`rg16`），zero-copy；新 texture 加入 residency set。Metal 4 不 retain 資源，slot 持有 `CVPixelBuffer` 到該幀 GPU 完成。回傳 `nil`（非 IOSurface-backed、不支援的格式）或格式與 `program.pixelFormat` 不符時 `abandon(slot)` 後 skip。
4. `program.uniforms(...)`：寫入 uniform ring。內容為 drawable 中的 destination、反轉 rotation／mirror 的 luma 座標仿射，以及 chroma 尺寸比與 chroma location 偏移；除 destination 外皆為正規化座標。co-sited 時偏移 `0.5 − 0.5/取樣比例` 個 chroma texel，4:4:4 為 0；4:2:2 只取 chroma location 的水平部分。
5. 設定 argument table：uniforms、luma、chroma、drawable。
6. `submitter.begin(slot)` 取得 command buffer；建不出 compute encoder 時 `abandon(slot)` 後 skip。
7. 編碼 compute pass：每個 drawable 像素一個 thread，destination 內取樣來源，外面寫黑色（即黑邊）。
8. `surface.willCommit`：`PresentationLog.record` 記錄 drawable 與 frame 的對應，掛 presented handler。
9. `submitter.commit(slot, drawable:, passes:)`：commit residency → `waitForDrawable` → commit → `signalDrawable` → `signalEvent` → `present()`。

### Shader

融合後的 kernel（`Modules/Metal4/Shaders/Display.metal`）：

```mermaid
flowchart LR
    U["DisplayUniforms<br/>destination：輸出中的位置<br/>lumaX / lumaY：反轉 rotation、mirror<br/>chroma：尺寸比與 chroma location 偏移"] --> F["flow_display（kernel）<br/>算 luma / chroma 座標，destination 外寫黑色"]
    F --> S1["bilinear 取樣 luma.r、chroma.rg<br/>= chromaUpsample + scale"]
    S1 --> S2["dequantize(bitDepth, range)<br/>ycbcrToRGB(matrix)<br/>= yuvToRGB"]
    S2 --> O["target.write(float4 RGB)"]
    FC["function constants<br/>MATRIX / FULL_RANGE / BIT_DEPTH"] -.-> F
```

### 閒置與停頓

- 每個 vsync 結尾，沒有待處理的 redraw 且畫面已無事可做（當前 generation 已上屏、或解碼已結束且 `FrameQueue` 為空、或尚無 pipeline 但已選出當前 generation 的幀）時：暫停中即 `stopVSync`；播放中只有 video drained（解碼已結束、佇列已空、最後一幀已顯示滿 duration）才停，即 EOF。
- vsync 停住（例如視窗被遮住）時，`RenderLoop` 的 fallback timer 改用 `catchUp` 依時鐘消耗到期的幀，避免 decoder 卡在 `highWater`；暫停且已選出當前 generation 的幀、或 video drained 時停用 timer。

## 縮放

程式碼裡沒有任何縮放倍率。`scale` 步驟由兩件事實現：

1. **`DisplayUniforms.destination` = `program.destination(in: drawable)`**：即 `plan.geometry.destination` 依 drawable／`outputSize` 的比例縮放（兩者相同時就是 plan 的值）；kernel 以像素中心換算 destination 內的 (u, v)，範圍外寫黑色。
2. **硬體 bilinear 取樣**：kernel 依 uniforms 算出每個像素對應的來源正規化座標，用 `filter::linear` 取樣 luma／chroma。

因此「來源多大 → 輸出多大」完全由 destination 大小決定；除 destination 外，uniforms 不隨視窗改變。destination 外的區域塗黑（letterbox）。

`destination` 的算法（`DisplayGraph.compile`）：

```text
displaySize = geometry.rotated(by: video.rotate).displaySize(for: sourceSize)   // geometry = VideoFormat.geometry
destination = letterbox(displaySize, in: outputSize)   // 保持比例、置中；尺寸四捨五入、原點捨去
outputSize  = Presenter.target.size                    // drawable 的實體像素
```

### 視窗大小影響縮放的路線

```mermaid
sequenceDiagram
    participant W as NSWindow
    participant V as MetalVideoView (Main)
    participant P as MetalLayerPresenter (Main)
    participant B as PresenterBridge
    participant S as DisplayBinding (Main)
    participant R as RenderLoop (Flow.Render)
    participant G as GPU

    W->>V: 視窗縮放／fit(video:)／全螢幕<br/>VideoHostView autoresizing → setFrameSize、layout
    Note over V: 也會因 backing scale、換螢幕觸發
    V->>V: geometryDidChange<br/>pixels = convertToBacking(bounds).rounded
    V->>P: viewGeometryDidChange(pixelSize, scale, screen)
    P->>P: layer.contentsScale = scale<br/>layer.drawableSize = pixels ①
    P->>P: update { target.size、refreshRateRange、headroom }（Mutex）
    alt target 有變
        P->>B: displayTargetDidChange()
        B->>S: Task @MainActor { recompile() }
        opt FramePacing 算出的 FrameRateRange 與上次不同
            S->>R: perform { setFrameRateRange(range) }
        end
        S->>S: makeDisplayPipeline<br/>DisplayGraphInput.outputSize = target.size
        S->>S: DisplayGraph.compile → letterbox → 新 destination
        alt plan 與上次相同（例如只有刷新率變了）
            S->>S: 不重建
        else plan 不同
            S->>S: MetalDisplayConnection.makePipeline<br/>新 DisplayProgram(outputSize, destination)，PSO 取快取
            S->>R: perform { setPipeline(new), requestRedraw() } ②
        end
    end
    R->>R: 下一個 vsync（暫停中 requestRedraw 會重啟 vsync 並重畫當前幀）
    R->>G: render：destination × (drawable / outputSize)
```

有兩條路線，作用時間不同：

| | ① 立即路線 | ② 正式路線 |
| --- | --- | --- |
| 觸發 | `layer.drawableSize` 在 main thread 同步更新 | `target.size` → `displayTargetDidChange` → `recompileDisplay` |
| 延遲 | 下一個 drawable 就是新尺寸 | 經一次 `Task @MainActor` 跳轉，再由 Render thread 換 pipeline |
| 效果 | 舊 plan 的 `DisplayProgram.destination(in:)` 把舊 destination 依 drawable／`outputSize` 比例拉伸到新 drawable | 依新 `outputSize` 重算 letterbox，destination 回到正確比例 |
| 暫停時 | 不生效：vsync 已停、幀與 plan 未變，不會 render，要等 ② | `requestRedraw` 重啟 vsync，以新 plan 重畫當前幀 |

兩者之間若長寬比改變，可能有一兩幀暫時變形。暫停時在 ② 之前沒有新 drawable，畫面由 Core Animation 依 `contentsGravity`（未設定，預設 `resize`）處理。一般視窗下 `PlayerWindowController.fit(video:)` 設了 `contentAspectRatio`，鎖住比例，所以這段變形與 letterbox 通常都不明顯；但載入時 plan 為 `nil` 則 `fit` 不縮放視窗也不鎖比例（見[建立期](#建立期)）。

### 範例

1920×1080 影片：

| 情境 | drawable（`target.size`） | `destination` | 縮放 |
| --- | --- | --- | --- |
| 1280×720 pt 視窗，Retina 2x | 2560×1440 | (0, 0, 2560, 1440) | 放大 1.33x |
| 3024×1964 全螢幕 | 3024×1964 | (0, 131, 3024, 1701) | 放大 1.575x，黑邊上 131、下 132 px |
| 同上再 `video.rotate = 90` | 3024×1964 | (959, 0, 1105, 1964) | displaySize 變 1080×1920，放大 1.02x，黑邊左 959、右 960 px |

### 不會影響縮放的變化

- 只有刷新率改變（換螢幕）：`DisplayGraphInput` 不含刷新率，plan 相同，不重建 pipeline；`refreshRateRange` 變化只重新計算 `FrameRateRange`，有變才在 Flow.Render 重設。
- `video.rotate`：不經過視窗，直接 `recompileDisplay`；`fit(video:)` 只在 `fileLoaded` 呼叫，因此旋轉後視窗比例不會跟著改。
