Flow 架構
範圍:macOS 定位:專為 Apple Silicon 最佳化的原生影片播放器,結合「播放引擎 + 影像處理框架 + 可替換模組」。
1. 設計目標與原則
- 目標:見
REQUIREMENTS.md。 - 原則:見
AGENTS.md。 - 核心/模組邊界:核心決定幀「怎麼流」(執行緒、佇列、時鐘、generation、何時處理與丟棄);模組決定幀「長什麼樣」(解析、解碼、像素變換、GPU 編碼、輸出)。核心只把幀當不透明的 IOSurface-backed
CVPixelBuffer搬運,不讀寫內容。
2. 技術選型
| 層級 | 選擇 | 模組 | 理由 |
|---|---|---|---|
| 語言 | Swift、Metal Shading Language、少量 C(vendored 橋接) | — | C 只用於 vendored 函式庫邊界 |
| 最低系統 | macOS 26(以 Xcode 27 建置) | — | 可直接使用 Metal 4、新版 SwiftUI;免去大量 #available 分支 |
| 容器解析(Demux) | FFmpeg libavformat(LGPL,動態連結) | FlowFFmpeg | 支援 MKV/MP4/TS/WebM/AVI 等幾乎所有容器與串流協定 |
| 視訊解碼 | VideoToolbox 硬解為主;dav1d(AV1)、libavcodec 軟解為備援 | FlowVideoToolbox、FlowDav1d、FlowFFmpeg | 硬解省電;不支援的編碼由軟解補足 |
| 音訊解碼 | libavcodec + AudioToolbox | FlowFFmpeg | 涵蓋 DTS/TrueHD/FLAC/Opus 等 |
| 音訊輸出 | AVAudioEngine(AVAudioSourceNode)、空間音訊/多聲道 | FlowAVAudio | 系統整合(AirPods 空間音訊、裝置切換) |
| GPU | Metal 4(MTL4CommandQueue、Argument Table) | FlowMetal4 | 低開銷提交 |
| 字幕 | 解碼:libavcodec(文字字幕轉 ASS 事件、點陣字幕轉 RGBA);渲染:libass(ASS) | FlowFFmpeg、FlowLibass | libavcodec 涵蓋多數字幕格式並統一為 ASS;libass 是 ASS 渲染事實標準 |
| Dolby Vision | libdovi(RPU 解析)+ 自製 Metal reshaping/tone mapping | FlowDolbyVision | 硬解只給 base layer,需要自行套用 RPU |
| 神經網路 | Core ML(ANE 優先,GPU 備援),輸入輸出為 IOSurface-backed CVPixelBuffer | FlowCoreML | ANE 省電且不佔 GPU;zero-copy |
| UI | SwiftUI;影片顯示層以 NSViewRepresentable 包 CAMetalLayer | FlowUI、FlowMetal4 | 顯示層需要精準控制 |
| Shader | 內建:MSL,建置期編譯為 metallib;使用者:mpv 相容 GLSL(glslang → SPIR-V → SPIRV-Cross 轉 MSL)或 MSL,執行期編譯 | FlowMetal4、FlowGLSL、FlowUserShaders | 內建零編譯成本;GLSL 沿用 mpv 生態,MSL 可用 Metal 專屬功能 |
| 模組 | 僅第一方,靜態連結、in-process;不支援外部載入 | Modules/* | 完整 SwiftUI、熱路徑零開銷;免外部程式碼的簽章與信任問題 |
| 持久化 | SwiftData | FlowLibrary | 媒體庫、播放紀錄、續播點 |
| 建置 | Xcode 專案 + 本地 Swift Packages;vendored 函式庫以 XCFramework 形式提供 | — | 模組邊界清楚、可單獨測試 |
| 發行 | GitHub Release + Homebrew Cask,見 .agents/skills/release/ | — | 不受沙盒與審查限制 |
授權:本體為 Apache-2.0。LGPL 元件(FFmpeg、FriBidi、libsmb2)以動態 framework 隨附,其餘靜態連結;清單與履行方式見 THIRD_PARTY_NOTICES.md。開源授權不含編解碼器專利,專利敏感格式優先交由 VideoToolbox/AudioToolbox。內建 shader 與移植程式碼限寬鬆授權;libplacebo、mpv 只作行為參考,不移植程式碼。
3. 系統總覽
3.1 結構
flowchart TB
subgraph App["App 層"]
UI[FlowUI<br/>播放控制/媒體庫/設定]
Platform[平台整合<br/>Now Playing/PiP/App Intents/MCP/快捷鍵]
Root[composition root<br/>選用模組]
end
subgraph Core["核心:契約 + 控制平面"]
Cmd[FlowController<br/>PlayerAPI 實作/Profiles]
Engine[FlowEngine<br/>狀態機/執行緒/時鐘/元件選擇]
Graph[FlowGraph<br/>Frame/Display Graph 拓撲]
Host[FlowModuleHost<br/>啟用/停用]
Reg[FlowRegistry<br/>已註冊實作與撤銷]
API[[FlowInterfaces<br/>擴充點協定]]
PAPI[[FlowPlayerAPI<br/>PlayerAPI/Command/Property/Event]]
end
subgraph Mods["模組:實作"]
Media[MediaSource/Demuxer<br/>FFmpeg、Network]
Dec[Decoder<br/>VideoToolbox、Dav1d、FFmpeg]
RT[NodeRuntime<br/>Metal4、VapourSynth、CoreML]
Nodes[Node/前端<br/>Effects、GLSL、UserShaders、DolbyVision]
Out[Presenter/AudioOutput<br/>Metal4、AVAudio]
Sub[Subtitle<br/>FFmpeg、Libass]
end
UI --> PAPI
Platform --> PAPI
Cmd -.->|實作| PAPI
API --> PAPI
Root --> Host
Cmd --> Engine
Engine --> Graph
Engine -->|選擇| Reg
Engine -->|呼叫| API
Graph -->|呼叫| API
Host -->|註冊| Reg
Reg --> API
Mods -.->|實作| API3.2 資料流
flowchart LR
Src[MediaSource] --> Dmx[Demuxer] --> PR((packet ring))
PR --> VD[VideoDecoder] --> FS((FrameSink)) --> Seg[Frame Graph 段<br/>FrameRuntime] --> FQ((FrameQueue)) --> DP[DisplayPipeline] --> Pr[Presenter]
PR --> AD[AudioDecoder] --> AR((PCM ring)) --> AO[AudioOutput] <-->|時間戳/速率| Clk((Clock))
PR --> SDec[SubtitleDecoder] --> SD[SubtitleRenderer] -->|overlay| DP
Clk <-->|vsync/顯示時間| Pr圓形為核心擁有的佇列與時鐘;方框為模組實作的介面。
4. 模組劃分(Swift Packages)
Flow/
├── Apps/
│ └── FlowMac/ # App target;只經 FlowComposition 的 FlowPlayer 與 PlayerAPI 操作播放器
├── Benchmarks/ # package-benchmark:micro、播放情境、size、baseline(docs/BENCHMARK.md)
├── Packages/
│ ├── Core/ # 不含任何實作的播放器核心;各 target 位於 Sources/<target>/
│ │ │ # 契約:模組、UI 與自動化編譯時依賴的型別與協定
│ │ ├── FlowBase/ # MediaTime、ColorInfo、FrameGeometry、TrackInfo、SPSC ring、Logger、Signpost、計數器
│ │ ├── FlowPlayerAPI/ # PlayerAPI、Command/Property/Event 與內建名稱;UI 與自動化唯一能碰的介面
│ │ ├── FlowInterfaces/ # 擴充點協定、FlowModule、資料平面型別(Packet、VideoFrame、PCMFormat)
│ │ │ # 控制平面:只有 Composition、IntegrationTests、Benchmarks 連結
│ │ ├── FlowRegistry/ # ComponentRegistry:依擁有者記錄與撤銷註冊
│ │ ├── FlowGraph/ # Frame/Display Graph 拓撲、分段、規格推導、預讀、bypass
│ │ ├── FlowController/ # PlayerController(PlayerAPI 實作)、按鍵綁定、Profiles
│ │ ├── FlowEngine/ # PlayerSession 狀態機、執行緒、FrameQueue/PCM ring、時鐘與 A/V 同步、Seek、元件選擇與 fallback、Governor
│ │ ├── FlowModuleHost/ # 模組啟用/停用
│ │ │ # 開發用:App 不連結;Core 自身的測試也用,故留在 Core
│ │ ├── FlowSampling/ # 行程/執行緒/記憶體取樣器;測試與 benchmark 用
│ │ └── FlowTestSupport/ # mock 模組、TestModuleContext、fixture 定位、MemorySource;Core、模組與整合測試共用
│ ├── Composition/ # FlowComposition:composition root,唯一組裝 Modules/* 之處;App 與 Benchmarks 共用
│ ├── IntegrationTests/ # 真實模組 + mock 輸出的端到端播放測試(§11.2)
│ ├── Library/ # FlowLibrary,App 層:媒體庫、播放紀錄、續播(SwiftData)
│ ├── MCP/ # FlowMCP,App 層:MCP server(Streamable HTTP,僅 loopback)
│ └── UI/ # FlowUI,App 層:SwiftUI 元件(控制列、OSD、設定頁)
├── Modules/ # 實作;模組本身靜態連結(LGPL 函式庫除外,見 §2)
│ ├── File/ # FileSource:本機檔案 MediaSource
│ ├── FFmpeg/ # FFmpegDemuxer(含 HLS)、FFmpegVideoDecoder、FFmpegAudioDecoder、FFmpegSubtitleDecoder、點陣字幕渲染
│ ├── VideoToolbox/ # VTVideoDecoder
│ ├── Dav1d/ # Dav1dVideoDecoder
│ ├── Network/ # SMB(libsmb2)/WebDAV/HTTP MediaSource
│ ├── AVAudio/ # AVAudioEngine AudioOutput
│ ├── Libass/ # ASS 字幕渲染
│ ├── Metal4/ # Metal4DisplayRuntime、MTL4ShaderNode、Presenter、內建步驟與 fallback 節點
│ │ ├── Plugins/ # MetalLibraryPlugin:建置期把 Shaders/*.metal 編譯為 FlowMetal4.metallib
│ │ └── Shaders/
│ │ ├── Include/Flow/ # Shader ABI(§7.4),使用者 .metal 亦依賴
│ │ └── *.metal # 色彩轉換、bilinear、基本 tone mapping、dither
│ ├── DolbyVision/ # libdovi RPU 解析 + reshaping 節點(依賴 Metal4)
│ ├── GLSL/ # GLSL → MSL 前端、GLSLShaderNode(依賴 Metal4)
│ ├── UserShaders/ # mpv `//!` 解析、匯入管理(依賴 Metal4、GLSL)
│ ├── Effects/ # 高品質縮放、去色帶、銳化、去交錯、動態 tone mapping(依賴 Metal4)
│ ├── VapourSynth/ # VSRuntime、VapourSynthFilterNode
│ └── CoreML/ # CoreMLRuntime、神經網路超解析/降噪節點
├── Vendor/ # 由 scripts/ 產生,不入庫
│ ├── libav*.xcframework # FFmpeg(libavformat、libavcodec、libavutil、libswresample);LGPL,動態;scripts/build-ffmpeg.sh
│ ├── Licenses/ # 各函式庫授權全文,隨 App 附於 Resources/Licenses/
│ ├── dav1d.xcframework
│ ├── libass.xcframework # 含 freetype/harfbuzz
│ ├── fribidi.xcframework # LGPL,動態
│ ├── libsmb2.xcframework # LGPL,動態;僅 FlowNetwork 使用
│ ├── libdovi.xcframework
│ ├── glslang.xcframework # 僅 FlowGLSL 使用(C API)
│ └── SPIRV-Cross.xcframework # 同上
├── Tests/(各 package 內) # 單元、Golden image、效能測試
└── docs/命名:資料夾與 package 名稱為角色(Core、Composition、UI、FFmpeg),product 與 Swift module 加 Flow 前綴(FlowUI、FlowFFmpeg),以免與 Apple framework(AVAudio、VideoToolbox)或其他 module 撞名。Core 以外皆為單一 product Flow<資料夾>。
分層:
| 層 | 位置 | 可依賴 |
|---|---|---|
| 契約 | FlowBase、FlowPlayerAPI、FlowInterfaces | 契約 |
| 控制平面 | FlowRegistry、FlowGraph、FlowController、FlowEngine、FlowModuleHost | 契約、控制平面 |
| 模組 | Modules/* | 契約、runtime 模組 |
| 組裝 | Composition | 契約、控制平面、模組 |
| 前端 | UI、MCP、Library | FlowBase、FlowPlayerAPI |
| App | Apps/FlowMac | 前端、FlowComposition、FlowBase、FlowPlayerAPI |
| 工具 | IntegrationTests、Benchmarks、FlowSampling、FlowTestSupport | 不限;App 不連結 |
依賴方向(只能向下):
Apps → {FlowUI, FlowMCP, FlowLibrary, FlowComposition}
FlowComposition → {FlowModuleHost, FlowEngine, Modules/*}
{FlowUI, FlowMCP} → FlowPlayerAPI
FlowModuleHost → FlowRegistry
FlowEngine → {FlowGraph, FlowRegistry, FlowController}
{FlowGraph, FlowRegistry} → FlowInterfaces → FlowPlayerAPI → FlowBase
FlowController → FlowPlayerAPI
Modules/* → [runtime 模組,如 Metal4] → FlowInterfaces邊界規則:
Core/只定義契約並掌管邏輯、生命週期與控制平面;不得匯入 SwiftUI、AppKit、Network、Metal、VideoToolbox、AVFAudio 與任何 vendored C 函式庫(CoreVideo 僅作為CVPixelBuffer/IOSurface 的不透明載體)。SwiftLint custom rule(core_banned_import、core_banned_link)檢查Packages/Core的 import 與連結。Modules/只能依賴契約、自身的 vendored 函式庫,以及所需的 runtime 模組;不得依賴控制平面。模組測試另可用FlowTestSupport(TestModuleContext記錄註冊)。- App、
FlowUI與FlowMCP只依賴FlowPlayerAPI(不依賴FlowInterfaces、FlowEngine),經PlayerAPI操作播放器;FlowPlayer只以@_spi(Benchmark)開放統計、display runtime、額外 presenter 與PresentationSurface給 Benchmarks,不暴露 registry 與PlayerSession。 - 依賴由
Package.swift宣告強制,scripts/check-deps.sh(prek)依上表檢查各 manifest、project.yml與 App 的 import,並檢查命名;package 的 import 由 explicit module build 限制在依賴閉包內;所有 package 與 App target 啟用MemberImportVisibility(只能使用本檔 import 的模組成員)、ExistentialAny、InternalImportsByDefault(import 預設 internal,公開 API 用到的型別才public import,vendored C 型別因此無法出現在公開 API),警告視為錯誤。 - 模組只公開
FlowModule型別;其餘實作為 internal,測試以@testable import存取。Core 與模組中只有 Benchmarks 需要的型別(如FrameQueue、PlaybackClock)以@_spi(Benchmark)開放,不設為public。 - 是否「必要、不可停用」由 App 組裝時標記(§7.1),不由資料夾決定;核心不內建任何實作,沒有模組時無法播放。
- 官方功能用
FlowInterfaces寫不出來即視為介面缺陷。
5. 播放引擎
5.1 執行緒模型
執行緒由 FlowEngine 建立與擁有;模組方法只在下表指定的執行緒被呼叫。
| 執行緒 | QoS/優先權 | 職責 |
|---|---|---|
| Demux | userInitiated | 呼叫 Demuxer.readPacket,寫入 packet ring |
| Video decode | userInitiated | 呼叫 VideoDecoder.decode;VT 等非同步解碼器由其 callback 寫入 FrameSink |
| Audio decode | userInteractive | 解碼並重取樣到輸出格式,寫入 PCM ring |
| Audio render | 即時(Core Audio IO 執行緒) | AudioOutput 從 PCM ring 取樣本,禁止上鎖與配置記憶體 |
| Frame Graph | userInitiated(Swift Concurrency) | 依 PTS 順序 pull 各 runtime 段,寫入 FrameQueue;非熱路徑 |
| Subtitle | userInitiated | 呼叫 SubtitleDecoder.decode;依 FrameQueue 中即將顯示的 PTS 預先呼叫 SubtitleRenderer.render,寫入 overlay 佇列 |
| Render | userInteractive,CAMetalDisplayLink | 選幀與已備妥的 OverlayFrame、呼叫 DisplayPipeline.render |
資料平面元件之間以核心提供的單一生產者/單一消費者 lock-free ring buffer 溝通;跨平面以 AsyncStream 回報事件(狀態變化、錯誤、統計)。
5.2 核心協定
定義於 FlowInterfaces;實作以 factory 註冊,由 FlowEngine 選擇與建立。
public protocol MediaSource: Sendable {
var url: URL { get }
var isSeekable: Bool { get }
func read(into buffer: UnsafeMutableRawBufferPointer, at offset: Int64) throws -> Int
var length: Int64? { get }
}
public protocol DemuxerFactory: Sendable {
var id: String { get }
func probe(_ source: any MediaSource) -> ProbeScore // .unsupported 表示不支援
/// `opener` 供需另開 URL 的 demuxer(HLS 的 playlist、segment)使用
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
// 待實作:
var attachments: [Attachment] { get } // 內嵌字型等(MKV attachment)
}
public protocol VideoDecoderFactory: Sendable {
var id: String { get }
func support(for format: VideoFormat) -> DecoderSupport // .hardware / .software / .none
/// 解出的幀寫入 `sink`;佇列由核心擁有
func make(_ format: VideoFormat, sink: 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` 在 Video decode 執行緒設定;不支援者可忽略
var skip: DecodeSkip { get set } // .none / .nonReference
}
/// 核心實作,不阻塞;背壓由 `FlowEngine` 暫停呼叫 `decode` 達成
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 世代,用來丟棄過期幀
// 待實作(VID-02):
public let hdr: HDRMetadata? // mastering display、CLL、DV RPU、HDR10+
}MediaSource、AudioDecoder、AudioOutput、SubtitleDecoder、SubtitleRenderer、Presenter 同樣採 factory + 實例協定(字幕見 §9)。
- HLS:屬 demux 層,無法以單一
MediaSource的位元組 offset 表達。playlist 以MediaSource開啟並經probe辨識,demuxer 再經MediaOpener開啟 variant playlist 與 segment;網路 I/O 仍由FlowNetwork的 HTTPMediaSource負責。FlowFFmpeg以 libavformat 的 HLS demuxer 實作,經AVFormatContext.io_open接上MediaOpener。直播時總時長未知(§6.3)。 - 顯示幾何:
FrameGeometry由 demuxer 自容器(MP4 display matrix、MKV 欄位)或碼流(SAR)取得,解碼器沿用 packet 上的值填入;像素保持儲存方向,由 Display Graph 的scale套用(§6.4)。
生命週期契約(所有元件適用):
| 階段 | 方法 | 執行緒 | 規則 |
|---|---|---|---|
| 建立 | Factory.make、configure | 控制平面(可 async) | 可配置記憶體、可拋錯;失敗時 FlowEngine 換下一個實作 |
| 處理 | readPacket、decode、render 等 | 資料平面(§5.1) | 不得 await;上鎖、配置記憶體、阻塞依執行緒分級(硬即時/軟即時/吞吐)限制,見 swift-realtime-media Skill |
| Flush | flush、seek、reset | 該元件的資料平面執行緒 | 只丟棄內部狀態,不釋放資源 |
| 重設 | configure | 控制平面,元件暫停時 | FlowEngine 保證不與處理並行 |
| 釋放 | deinit | 控制平面 | FlowEngine 保證在最後一次處理之後 |
輸出契約:所有解碼器輸出 IOSurface-backed CVPixelBuffer 並填妥 ColorInfo,不做色彩轉換;YUV→RGB 由 Display Graph 掌控以保證品質一致。輸出須依 PTS 遞增,重排序由解碼器模組負責(如 VT:HEVC 即使開啟 temporal processing 仍依解碼順序輸出,由 FrameReorder 依 PTS 重排)。
5.3 解碼策略
FlowEngine依VideoFormat(codec、profile、bit depth、解析度)詢問所有已註冊 factory 的support():.hardware優先於.software,同級依 App 組裝順序。- 建立或解碼發生致命錯誤時,
FlowEngine改用下一個 factory,並從前一關鍵幀重新解碼。 - 實作細節屬各模組,例如
FlowVideoToolbox依內容選輸出格式(8-bit →420YpCbCr8BiPlanar;10-bit/HDR →420YpCbCr10BiPlanar;VideoRange/FullRange依來源 range;ProRes 4444 →4444AYpCbCr16),軟解模組寫入CVPixelBufferPool的 IOSurface buffer。
5.4 時鐘與 A/V 同步
時鐘與同步策略屬 FlowEngine;AudioOutput 與 Presenter 只回報時間戳。
時鐘
PlaybackClock為錨點映射(hostTime, mediaTime, rate),以原子快照發布,其他執行緒無鎖讀取;display-resample由 Render 執行緒依 vsync 推進,audio由 Audio render 執行緒依音訊時間更新。- 音訊時間=
AudioOutput回報的 (sample time, host time) 扣除輸出延遲(裝置、串流、safety offset、緩衝);裝置變更時重新查詢延遲。無音軌時以 host time 推進。 - 換裝置、音軌結束、underrun 時重新錨定,媒體時間保持單調、不跳躍。
AudioOutput停止並重啟後,回報的 host time 須與已消耗的樣本一致;核心另保證「聽到的時間」不早於現在,防止輸出的 host time 停住時時鐘倒退。- 核心經
HostClock協定取得時間,測試時注入虛擬時鐘。
同步模式(Property video.sync)
display-resample(預設):vsync 主導,視訊每幀固定 N 個 vsync、速度調整 ≤ ±1%,音訊同比例重取樣並修正漂移;條件為刷新率在 ±1% 內為輸出幀率的整數倍。audio:音訊時鐘主導,視訊丟/重複幀,音訊不調整;用於其他情況。- 輸出幀率一律取 Frame Graph 輸出的
StreamSpec.fps,不用來源幀率。 - 條件不符時自動降為
audio;條件恢復且穩定一段時間後才回到display-resample(遲滯)。實際模式顯示於統計 OSD。 - 刷新率:
FlowEngine依輸出幀率設定preferredFrameRateRange(ProMotion/VRR,如 24p → 120Hz 的 5:5 pacing),全螢幕時可請求切換刷新率。刷新率只由輸出幀率決定;依刷新率決定輸出的節點只讀顯示器能力(§6.5),避免兩者互相追逐。
選幀(Render 執行緒,每 vsync)
- 以 display link 的
targetPresentationTimestamp加上量測到的呈現延遲(實際上屏時間 −targetPresentationTimestamp),換算該次繪製實際顯示時的媒體時間。 display-resample依累計誤差分配每幀的 vsync 數(如 3:2 交替);audio選pts ≤ t的最新幀,邊界加遲滯避免 cadence 抖動。- 交由
DisplayPipeline繪製;Presenter回報實際顯示時間,用來校正呈現延遲並統計延遲幀與丟幀。 - vsync 停住(如視窗被遮住)時,Render 執行緒以備援 timer 依時鐘消耗到期的幀,避免解碼卡在高水位。
落後時降級(依序、含遲滯,每一步記錄於統計 OSD)
- 無時間窗與
.retimed節點:降低節點品質層級(Governor,§6.7)→ Frame Graph 前丟幀 → 解碼器略過非參考幀(VideoDecoder.skip)→ 顯示端丟幀。 - 有時間窗或
.retimed節點:降低節點品質層級(Governor,§6.7)→ 降低重定時候選(§6.5,如 120 → 60)→ bypass 該節點 → 顯示端丟幀。
丟輸入幀會讓時間窗缺幀,故有時間窗節點時不在 Frame Graph 前丟幀。
起播、Seek、暫停、倍速
- 起播與 seek:新
generation的首個音訊樣本與視訊幀都就緒才啟動時鐘;音訊裁掉目標時間前的樣本,視訊先顯示首幀。 - 暫停時
rate = 0並停止 display link,只在畫面需更新(seek、字幕/OSD、顯示目標或參數變更)時繪製單幀;Frame Graph 預讀補滿即停止。playback.rate改變時重新錨定,音訊以保持音高的 time-stretch 播放。 - EOF:音訊先結束改以 host time 推進;視訊結束時停在最後一幀。
audio.delay只修改音訊時間映射;字幕依實際顯示幀的pts對齊,丟幀時不失步。
5.5 狀態機與 Seek
idle → loading → ready ⇄ playing ⇄ paused
↓ ↓
seeking ←────┘ → (ready | playing | paused)
任何狀態 → error/ended- Seek 時控制平面只遞增
generation;各 ring 由消費端丟棄舊世代的幀與樣本(控制平面不清空 SPSC ring,以維持單一生產者、單一消費者),各元件的flush在其資料平面執行緒呼叫,不需要同步等待。 - Seek 完成條件:新世代的首幀已渲染,或首幀就緒後已等滿
displayWaitLimit(display link 被節流時);完成後回到 seek 前的狀態,暫停中 seek 仍為暫停。 SeekMode:.keyframe(拖動時即時預覽)與.exact(放開後精準定位,從前一關鍵幀解碼至目標)。- Demux 快取:核心提供前向快取(依秒數 + 位元組上限)與後向快取,讓短距離倒退無需重新讀取網路。
5.6 Presenter
Presenter 協定定義於核心,回報顯示目標(尺寸、像素格式、刷新率與可用範圍;EDR headroom 待實作)與每幀實際顯示時間。顯示目標變化時通知 FlowEngine:尺寸、像素格式變化才重新編譯 Display Graph(DisplayPlan 相同即沿用);EDR headroom、刷新率等參數只更新 uniform 與 preferredFrameRateRange,並重新判斷同步模式(§5.4)。實作在 FlowMetal4:
MetalLayerPresenter:預設。CAMetalLayer,EDR 開啟時wantsExtendedDynamicRangeContent = true、rgba16Float+ extended linear Display P3。目前(VID-02 前)把 layer colorspace 標成來源色彩空間,drawable 依位元深度用bgra8Unorm/bgr10a2Unorm,色域與轉移函數交給系統 compositor;HDR 是否改走 EDR 於 VID-02 決定。SampleBufferPresenter:子母畫面(PiP)。將 Display Graph 輸出寫回 IOSurfaceCVPixelBuffer→CMSampleBuffer→AVSampleBufferDisplayLayer,搭配AVPictureInPictureController.ContentSource(sampleBufferDisplayLayer:),讓 PiP 也保有影像處理效果;controlTimebase由PlaybackClock驅動。
6. 影像處理框架
6.1 雙圖模型
影像處理依時間域分成兩張圖:
flowchart LR
Dec[Decoder] -->|FrameSink| FG[Frame Graph<br/>使用者排序/預先處理]
FG --> Q[FrameQueue]
Q --> DG[Display Graph<br/>每 vsync/固定 hook]
DG --> P[Presenter]| Frame Graph | Display Graph | |
|---|---|---|
| 驅動 | 依 PTS 順序 pull,領先播放位置執行 | 每 vsync 一次,不得阻塞 |
| 節點 | runtime 為 FrameRuntime 的節點,可混用多個 runtime | 僅 ShaderNode,且 runtime 為目前的 DisplayRuntime |
| 順序 | 使用者任意排序 | 依 hook 固定 |
| 能力 | 時間窗內前後幀、可改幀率/幀數、延遲不固定 | 一進一出,可讀已顯示的歷史幀 |
| 限制 | 不得依賴顯示參數(視窗尺寸、EDR headroom、OSD);需要時由節點訂閱唯讀 Property 自行重設 | 不得依賴未來幀 |
依賴顯示參數的步驟(縮放到輸出尺寸、tone mapping、字幕/OSD、dither)固定在 Display Graph,其餘處理放 Frame Graph。
6.2 節點與 Runtime
核心只定義節點階層與 runtime 介面;節點(做什麼)、runtime(怎麼執行)、語言前端(怎麼寫) 三者分離:
// FlowInterfaces(不依賴任何 GPU API)
public protocol RenderNode: AnyObject, Sendable {
var descriptor: NodeDescriptor { get } // id、placement(.frame / .display(hook))、時間窗、需求色彩空間、品質層級數、是否逐像素
var runtime: RuntimeID { get } // 由哪個 runtime 執行
/// 格式或參數改變時呼叫;回傳輸出規格,讓 FlowGraph 規劃資源與輸出時間
func configure(input: NodeSpec, context: NodeContext) throws -> NodeSpec
/// Governor 切換品質層級(0 為最高,§6.7);由 runtime 在兩次處理之間呼叫
func setQuality(_ level: Int)
}
/// 非同步,可用 CPU/外部 runtime;只能放 Frame Graph
public protocol FilterNode: RenderNode {}
/// 同步編碼 GPU 指令、不得阻塞;可放的圖由 runtime 能力決定
public protocol ShaderNode: RenderNode {}
/// 只提供身分,供 FlowGraph 依節點的 `runtime` 分段與驗證
public protocol NodeRuntime: AnyObject, Sendable {
var id: RuntimeID { get }
}
/// 能執行 Frame Graph 段
public protocol FrameRuntime: NodeRuntime {
/// 把連續同 runtime 的節點編成一段,經 `input` 向上游要幀
func makeSegment(_ nodes: [any RenderNode], input: any FrameSource) throws -> any FrameSource
}
/// 能執行 Display Graph;同時只有一個
public protocol DisplayRuntime: NodeRuntime {
/// 依已排序的 hook 計畫建立 pipeline;內建步驟與 fallback 由此 runtime 提供
func makePipeline(_ plan: DisplayPlan, presenter: any Presenter) throws -> any DisplayPipeline
}
public protocol DisplayPipeline: AnyObject {
/// Render 執行緒,每 vsync 最多一次;不得阻塞。幀與 plan 都沒變時核心不呼叫,畫面維持上一個 drawable
func render(_ frame: VideoFrame, at time: HostTime)
// 待實作(SUB-02):overlay 由 runtime 擁有的佇列傳入,不在每個 vsync 配置陣列
}- 具體編碼介面屬 runtime 模組:如
FlowMetal4定義MTL4ShaderNode: ShaderNode(encode(_:input:output:),輸出為MTLTexture);runtime 以as?取得自己的節點型別。 - 逐像素節點:輸出像素只取決於輸入的同一像素、且不改尺寸的節點(YUV→RGB、線性化、tone mapping 等)於 descriptor 標示逐像素,可與相鄰步驟合併成同一個 pass(§6.6);需讀鄰近像素或改變尺寸者為取樣節點。
- 能力即規則:runtime 依需要組合
FrameRuntime/DisplayRuntime(如 Metal 4 runtime 兩者皆實作、VSRuntime只實作前者)。FlowGraph編譯時驗證:Frame Graph 節點的 runtime 須為FrameRuntime;Display Graph 只接受 runtime 為目前DisplayRuntime的ShaderNode,違反者不得加入。 - 擴充方式:
- 節點:實作某 runtime 的節點協定(如
FlowEffects的縮放器實作MTL4ShaderNode)。 - Shader 語言:前端模組,編譯為 runtime 可執行的形式,節點實作該 runtime 協定(如
FlowGLSL→ MSL →GLSLShaderNode: MTL4ShaderNode)。 - 執行環境:runtime 模組,實作
FrameRuntime/DisplayRuntime與自己的節點協定(如FlowVapourSynth、FlowCoreML)。
- 節點:實作某 runtime 的節點協定(如
連續同 runtime 的節點不論來源語言都合併進同一段(同一個 command buffer、共用 heap aliasing 與 GPU 計時);跨 runtime 邊界才有轉換成本。
職責分工:
FlowGraph(核心) | NodeRuntime(模組) | |
|---|---|---|
| 拓撲 | 依使用者順序/hook 排序、依 runtime 分段、驗證 placement、切分 pass | — |
| 規格 | 呼叫 configure 推導 NodeSpec、時間窗、輸出時間格 | — |
| 時間 | 驗證 PTS 單調遞增且不偏離輸入;違反時 bypass | 依時間窗與輸出時間驅動節點 |
| 資源 | 預讀深度與記憶體上限 | 紋理、heap aliasing、buffer pool |
| 執行 | 排程、generation、逾時 bypass | 合併 pass、編碼與提交、pipeline cache、GPU 計時 |
| 轉換 | 決定哪裡需要色彩空間或格式轉換 | 提供轉換實作 |
6.3 Frame Graph
一切皆 FrameSource:解碼器輸出是根,每個 runtime 段包住上游再成為新的 FrameSource。
public protocol FrameSource: AnyObject, Sendable {
/// 尺寸、格式、色彩、幾何、名目 fps;總時長可為 nil(直播)
var spec: StreamSpec { get }
/// 依 PTS 遞增回傳下一幀;nil = 本 generation 結束
func next() async throws -> VideoFrame?
/// seek 或參數變更時由 FlowGraph 呼叫;丟棄內部狀態
func reset()
}-
串流模型:以 PTS 而非幀編號定位,總幀數與時長可未知(VFR、直播)。每個 generation 是一段獨立串流,從 seek 落點開始(
.keyframe為關鍵幀、.exact為目標幀),以nil結束。需要隨機存取的 runtime 在段內自行轉換(§7.6)。 -
根:核心把
FrameSink包成根FrameSource,以有界佇列把 push 轉成 pull:下游 await 至新幀到達;佇列達高水位時FlowEngine暫停呼叫decode。demux EOF 後呼叫drain,排空即回傳nil。 -
編譯:依使用者順序,連續同 runtime 的節點合併成一段交給
FrameRuntime.makeSegment,各段首尾相接(如連續MTL4ShaderNode編成單一 command buffer、連續 VS 節點編成單一 VS 圖)。 -
格式:段間一律傳 IOSurface-backed
VideoFrame;預設維持原生 YUV(4Krgba16Float一幀約 66 MB,不利預讀),節點可宣告需要線性 RGB,由下游 runtime 轉換。 -
時間:輸出幀自帶
pts/duration,改變幀率或幀數(補幀、IVTC)的節點經NodeSpec.timing宣告(§6.5);FlowGraph只驗證 PTS 單調遞增且輸出時間不偏離輸入(總時長不變),違反時 bypass 該段,以免音訊失步。StreamSpec.fps為名目值,僅供預讀估算與顯示同步。 -
顯示資訊:框架不因顯示參數變化重新編譯 Frame Graph;節點若要依顯示參數決定輸出,自行訂閱唯讀 Property 並更新設定,走一般重新編譯流程。補幀等重定時節點例外,輸出幀率見 §6.5。
-
即時性:前以
FrameSink、後以FrameQueue隔離,以預讀深度(預設約 0.5 s,並受記憶體上限約束)吸收延遲抖動;高輸出幀率下記憶體上限使預讀低於約 0.2 s 時,改觸發 §5.4 降級而非繼續縮短。不屬熱路徑,可使用 Swift Concurrency。 -
Seek/參數變更:遞增
generation、取消進行中的 pull,由根往下呼叫各段reset後從新位置重新處理,避免已預讀的舊結果延遲生效。 -
失敗:某段逾時或拋錯時 bypass 該段並通知使用者,播放不中斷。
-
可量測:每段耗時、預讀水位、跨 runtime 邊界數顯示於統計 OSD。
6.4 Display Graph 與 Hook Points
內建步驟(chroma 升取樣、YUV→RGB、線性化)之間開放 Hook Point,順序對齊 mpv,以相容 mpv 使用者 shader(§7.5):
flowchart LR
A[FrameQueue] --> P[plane] --> CU([chroma 升取樣]) --> PS[plane-scaled] --> MG([合併]) --> N[native]
N --> RGB([YUV→RGB]) --> M[main] --> LIN([線性化*]) --> L[linear*] --> SIG([sigmoid 化*]) --> SG[sigmoid*]
SG --> S[scale] --> SD[scaled] --> T[tonemap] --> O[output] --> OV[overlay] --> D[dither]* 依 render.scale-light 啟用。
| Hook | mpv 對應 | 色彩空間/格式 | 典型節點 |
|---|---|---|---|
plane | LUMA、CHROMA、ALPHA、RGB、XYZ | 解碼器原生單一平面 | 亮度預放大(FSRCNNX、RAVU)、chroma 重建(KrigBilateral) |
plane-scaled | CHROMA_SCALED、ALPHA_SCALED | 單一平面,已縮放到 luma 尺寸 | |
native | NATIVE | 合併平面,4:4:4 原生 YUV | 去色帶 |
main | MAIN、MAINPRESUB | 非線性 RGB(來源轉移函數) | Anime4K |
linear | LINEAR | 顯示線性 RGB,rgba16Float | |
sigmoid | SIGMOID | sigmoid 化的顯示線性 RGB | |
scale | PREKERNEL → 縮放器 → POSTKERNEL | 依 render.scale-light | 內建縮放器(EWA Lanczos、Bicubic、FSR) |
scaled | SCALED | 輸出解析度,sigmoid 已還原 | 銳化、Film grain |
tonemap | — | 顯示線性,依 EDR headroom | BT.2390、Hable、動態 tone mapping |
output | OUTPUT | 顯示參照,目標轉移函數編碼 | |
overlay | — | 同 output | 字幕、OSD(OverlayFrame 由 runtime 合成) |
dither | — | 目標 drawable 格式 | Dither |
- 內建步驟:chroma 升取樣、YUV→RGB、線性化為必填插槽,預設節點由
DisplayRuntime提供;模組可取代(如FlowDolbyVision於有 RPU 時取代 YUV→RGB 做 reshaping)。線性化在render.scale-light為gamma時延到tonemap前。 - 幾何:
scale依VideoFrame.geometry與 Propertyvideo.rotate/video.aspect套用旋轉、鏡像與像素長寬比,並等比縮放置中於輸出(letterbox);Frame Graph 與scale之前的 hook 皆處理未旋轉的儲存畫面(同 mpv)。 - Fallback:單選 hook 的 fallback 亦由
DisplayRuntime提供(FlowMetal4:scale硬體 bilinear、tonemap基本 BT.2390、ditherordered),只啟用必要模組時畫面仍正確。 - Hook 語意(由
FlowGraph處理):scale、tonemap、dither為單選,模組節點取代 fallback;其餘 hook 可疊加,同一 hook 先執行模組節點、再依render.user-shaders順序執行使用者 shader;scale的PREKERNEL/POSTKERNEL節點包在所選縮放器前後。 - 平面:
plane/plane-scaled節點須指定平面,如.display(.plane(.luma));來源為 RGB 時只有RGB觸發(同 mpv)。 - 縮放光域:Property
render.scale-light(auto/linear/sigmoid/gamma)決定linear/sigmoid是否觸發及scale、scaled的光域。auto對齊 mpv 預設:升頻 sigmoid、降頻線性、HDR 降頻 gamma(避免極端亮度差放大 ringing)。 - 尺寸:任一 hook 的節點都可改變尺寸;chroma 升取樣以 hook 後的 luma 尺寸為目標,已同尺寸則略過(如 KrigBilateral);
scale只處理剩餘倍率。 - 實體化:hook 只是邏輯位置;只有掛上節點或結果被讀取時才寫出紋理,否則前後步驟合併在同一個 pass(同 mpv,§6.6)。
- 紋理命名空間:每個 hook 的結果以 mpv 名稱(
LUMA、NATIVE、MAIN、PREKERNEL…)登記,連同//!SAVE紋理供後續節點綁定;FlowGraph依最後一個讀取者決定生命週期。 - 座標:每個紋理帶相對來源的 offset/transform(對應 mpv
NAME_off/NAME_rot),由FlowGraph沿 hook 傳遞,最後由縮放器補償,避免預放大造成的半像素偏移。 - 線性光約定:顯示線性(套用來源 EOTF),1.0 = SDR 參考白(203 nits,BT.2408),與 EDR 一致;兩張圖與所有模組共用。
output編碼:一律為目標轉移函數編碼;drawable 為 extended linear 時,由 runtime 在dither轉換。
節點可在 descriptor 宣告需要的色彩空間,FlowGraph 決定插入位置,轉換節點由 runtime 提供。
6.5 NodeSpec 與時間
public struct NodeSpec: Sendable {
public var texture: TextureSpec
/// 相對來源座標的 offset/transform;對應 mpv `NAME_off`/`NAME_rot`
public var transform: TextureTransform
/// `.passthrough` 或 `.retimed(fps:)`;Display Graph 只接受 `.passthrough`
public var timing: FrameTiming
}- 時間窗:descriptor 宣告需要前 N/後 M 幀;Display Graph 的 M 必須為 0。generation 起點與 EOF 處不足時以邊界幀補齊並標記;窗內幀由
FlowGraph以滑動視窗保留,計入預讀記憶體上限。 - 重定時:
.retimed節點只宣告輸出幀率,FlowGraph依此產生輸出時間格,runtime 對每個輸出時間驅動一次節點;節點仍是一次一張輸出,不管理 PTS。 - 重定時候選:節點從
NodeContext.retimeRates選擇輸出幀率(可依自身上限選較小者)。FlowEngine依display.max-refresh-rate、playback.rate與目前降級層級提供最高刷新率 ÷ (k × rate)(k 為正整數)的候選,確保display-resample成立,也避免與刷新率互相追逐(§5.4)。候選變化視同參數變更(§6.3)。 - 節點輸入(型別由 runtime 定義,語意由核心規定):時間窗內的幀(依實際 PTS,VFR 亦正確)、輸出 PTS、輸出時間在前後輸入幀間的相位(0–1)、HDR metadata、使用者參數。
6.6 Graph 編譯與資源管理
FlowGraph(核心):
- 編譯:Frame Graph 依使用者順序分段;Display Graph 依 hook 排序、套用單選語意與 fallback → 建立紋理命名空間,略過綁定不到紋理的節點 → 標記需要的色彩轉換 → 呼叫
configure推導每個節點的輸入/輸出規格 → 切分 pass → 產生DisplayPlan。兩張圖都只在來源格式、輸出尺寸、節點組合變化時才重新編譯;Display Graph 另含video.rotate與 drawable 像素格式。DisplayPlan與上次相同時不重建 pipeline。 - 切分 pass:中間紋理的讀寫是主要成本(4K
rgba16Float每多一個 pass 約多讀寫 133 MB),故只在取樣前切開:- 逐像素步驟接到目前 pass 的尾端,中間值留在暫存器。
- 遇到取樣步驟時,結束目前 pass 並寫出紋理,取樣步驟開新 pass。
- 硬體 bilinear 取樣(如 bilinear chroma 升取樣)視為逐像素,可放在 pass 開頭。
scale為 1:1 或硬體 bilinear 時不切開,直接以輸出座標取樣來源平面(同 libplacebo);先內插再做非線性步驟,屬可接受的近似,只適用於render.scale-light為gamma(或尚未支援光域的 fallback);linear/sigmoid須在線性化後縮放,scale另開 pass。若 chroma 也是 bilinear 且沒有取樣節點,整條管線只有一個 pass。overlay、dither接到最後一個 pass;是否另寫出output由 runtime 逐幀決定(見下方「重用」)。- 使用者 shader 每個
//!HOOK為獨立 pass,不合併(同 mpv)。
- 監控:彙整 runtime 回報的耗時供 Governor(§6.7)使用,逾時即 bypass 該節點或段。
DisplayRuntime(FlowMetal4):
- 資源:暫存紋理由
MTLHeap配置並依生命週期(最後一個讀取者)做 aliasing,4K 管線的暫存記憶體可降低 40–60%;Frame Graph 段輸出要進FrameQueue,改由CVPixelBufferPool配置。 - 合併 pass:依
DisplayPlan把同一 pass 的步驟編成單一 kernel;Frame Graph 的 Metal 段同此規則。- 只含內建步驟:一個 uber kernel,以 function constant 開關各步驟並特化 pipeline;組合有限,全部預先建立。
- 含
MTL4PointwiseNode:各節點提供 MSL 函式片段與參數,由 runtime 與內建步驟依序串成 kernel 背景編譯,以整串的雜湊快取;就緒前比照下方「Pipeline cache」先 bypass。
- 只用 compute:所有 pass 皆為 compute kernel,不用 render pass。驅動依行程用過的 encoder 種類配置 GPU context(M4 Max:compute 160 MB、render 224 MB、兩者並用約 290 MB),單一種類最省且路徑單純。render 專屬功能的替代:
- mpv fragment 型 hook 編成 kernel;
dFdx等微分以 quad shuffle 模擬,隱含 LOD 取樣改為level(0)。 - 字幕不畫四邊形,改取樣 overlay 紋理(§9)。
- 不用 MetalFX(內部為 render),縮放器以 kernel 實作。
- mpv fragment 型 hook 編成 kernel;
- 提交:一幀一個 command buffer(重複使用 command allocator + argument table),最多 3 幀 in-flight,以
MTLSharedEvent控制。 - Pipeline cache:所有
MTLComputePipelineState由MTL4Compiler在背景預先建立並快取於記憶體(模組啟用時 prewarm 內建組合),避免首次啟用節點時卡頓;跨啟動的快取以MTL4Archive持久化(待實作);首幀不等待,pipeline 未就緒的選配節點先 bypass,就緒後再啟用。 - 計時:每個 command buffer 以
MTL4CommitFeedback的 GPU 起訖時間量測;多 pass 時改以MTL4CounterHeap逐 pass 量測(待實作)。結果回報給FlowGraph與統計 OSD;合併的步驟共用一個量測值。 - 收尾:
overlay、dither併入最後一個 pass 的 kernel:取樣 overlay 紋理 blend、dither 後寫入 drawable;中間值留在暫存器,且維持overlay→dither順序。 - 重用:同一幀可能重繪(跨多個 vsync 或暫停)、有 overlay,且管線不只一個 pass 時,才寫出
output,後續重繪只重做overlay之後;其餘情況重繪時從來源重算(單一 pass 時重算比讀output省頻寬)。overlay 未變時不提交,CAMetalLayer維持顯示上一個 drawable。
6.7 Governor
依處理預算自動切換節點品質層級,讓昂貴節點(神經網路超解析、高階縮放器)負載過高時先降品質而非直接 bypass。由 FlowEngine 決策,FlowGraph 經 runtime 套用。
- 品質層級:節點於
NodeDescriptor宣告層級數(如 AI 模型大小、縮放器 kernel),未宣告者只有一級。各層級輸出的NodeSpec須相同,故切換不重新編譯、不遞增 generation、保留已預讀的幀。 - 預算:
- GPU:兩張圖所有 pass 的 GPU 時間佔經過時間的比例 ≤ Property
render.gpu-budget(預設 0.8),依 runtime 的 GPU 計時(§6.6)。 - Frame Graph:預讀水位持續下降視同超出預算,涵蓋 CPU、ANE 等非 GPU runtime。
ProcessInfo.thermalState達.serious或啟用低耗電模式時調降預算。
- GPU:兩張圖所有 pass 的 GPU 時間佔經過時間的比例 ≤ Property
- 決策:超出預算時,將近期耗時最高且仍可降的節點降一級;持續低於預算一段時間才逐級回升,回升後立即超出則暫時鎖定該層級,避免來回切換。
- 與降級的關係:為 §5.4 落後降級的第一步;所有節點降至最低層級仍不足才進入後續步驟。
- 控制:Property
render.governor(auto/off);各節點目前層級顯示於統計 OSD。
7. 模組
7.1 模組模型
所有實作(解碼器、runtime、節點、輸出、功能)皆為第一方模組:原始碼位於 Modules/,靜態連結進 App,由 composition root 組裝。不支援外部模組(無 bundle、manifest、下載或安裝)。
// Packages/Composition/Sources/FlowComposition/Modules.swift:唯一知道用了哪些實作的地方
import FlowAVAudio
import FlowFFmpeg
import FlowFile
import FlowMetal4
import FlowVideoToolbox
public enum FlowModules {
public static let essential: [ModuleEntry] = [
.essential(FileModule.self),
.essential(FFmpegModule.self), // 替換:改這行與對應 import(另加一行 package 依賴)
.essential(VideoToolboxModule.self),
.essential(AVAudioModule.self),
.essential(Metal4Module.self),
// .optional(EffectsModule.self), // 選用模組
]
}-
FlowPlayer:
FlowComposition以FlowPlayer組裝ComponentRegistry、PlayerController、ModuleHost、PlayerSession,四者皆不對外公開。App 只用api(PlayerAPI)、makeVideoView()、attach(_:);影片 view 由PresenterFactory.makeSurface()提供,App 與 composition root 都不需知道 presenter 型別。Benchmarks 經同一個FlowPlayer組裝,量到的就是發行的。 -
替換:同一介面的實作可互換,改模組名稱與 import 即可;依賴某 runtime 的模組(如
FlowGLSL依賴FlowMetal4)需隨該 runtime 一起替換。 -
必要性:
.essential不可停用;.optional可於執行期停用與重新啟用。 -
in-process:模組本身不使用 ExtensionKit/XPC;完整 SwiftUI、直接呼叫框架 API、熱路徑零開銷。
-
Library validation:主程式不載入外部程式碼,Hardened Runtime 下無需
disable-library-validation;需執行外部程式碼的模組(如 VapourSynth)一律交由 helper 行程。
不提供 Script(JavaScript):帶來額外 runtime 與維護負擔。
7.2 Module API
public protocol FlowModule: AnyObject, SendableMetatype {
/// 穩定識別碼;預設為完整型別名稱
static var id: String { get }
init()
/// 只在這裡註冊;停用時由 FlowModuleHost 撤銷所有註冊
@MainActor func activate(_ context: any ModuleContext) throws
@MainActor func deactivate()
}
@MainActor
public protocol ModuleContext {
var media: any MediaRegistry { get } // MediaSourceFactory、DemuxerFactory、外部字幕來源
var decoders: any DecoderRegistry { get } // VideoDecoderFactory / AudioDecoderFactory / SubtitleDecoderFactory
var outputs: any OutputRegistry { get } // AudioOutputFactory、PresenterFactory
var runtimes: any RuntimeRegistry { get } // FrameRuntime / DisplayRuntime
var player: any PlayerAPI { get } // Command / Property / Event(§8)
// 待實作:
var subtitles: any SubtitleRegistry { get } // SubtitleRendererFactory
var nodes: any NodeRegistry { get } // 可供使用者加入的節點類型
var ui: any UIRegistry { get } // 分頁、設定面板、選單、OSD 元件
var storage: any ModuleStorage { get } // 模組專屬設定與檔案
}- 能力可任意組合:一個模組可同時註冊解碼器、runtime、節點、UI 分頁並呼叫 API。
- UI:
ui接受原生 SwiftUIView,與內建畫面無差別。 - 版本:與 App 一起建置,
FlowInterfaces不需維持 ABI 穩定,可隨需求重構。 - 停用:撤銷所有註冊即生效;播放中的媒體沿用已建立的元件,下次載入時
FlowEngine依 §5.3 改選其他實作。重新啟用後恢復原本的偏好順序。 - 故障:GPU 或 Frame Graph 段逾時時,立即 bypass 該節點或段並通知使用者,播放不中斷。
7.3 範例:解碼器模組(Modules/FFmpeg)
- 註冊
FFmpegDemuxerFactory、FFmpegVideoDecoderFactory(.software)、FFmpegAudioDecoderFactory、FFmpegSubtitleDecoderFactory、FFmpegBitmapSubtitleRendererFactory。 - 目前已註冊
FFmpegDemuxerFactory、FFmpegAudioDecoderFactory。 - 只匯入 libav*/libswresample XCFramework 與
FlowInterfaces、FlowBase;C 型別不越過模組邊界。 - 新增其他解碼器(如另一個 AV1 實作)=新增模組實作
VideoDecoderFactory,選擇與 fallback 由FlowEngine處理。
7.4 範例:Metal 4 Runtime(Modules/Metal4)
- Runtime:
Metal4DisplayRuntime實作DisplayRuntime(§6.6 資源、提交、pipeline cache、計時);Frame Graph 上線時一併實作FrameRuntime。 - 節點協定:
MTL4ShaderNode: ShaderNode,供其他模組實作;MSLShaderNode為以 MSL 原始碼建立的通用實作。逐像素節點改實作MTL4PointwiseNode: ShaderNode(提供 MSL 函式片段與參數,不自行 dispatch),以便合併 pass(§6.6)。 - 內建節點:色彩轉換、chroma 升取樣、線性化、fallback 縮放器/tone mapping/dither、overlay 合成。
- Presenter:
MetalLayerPresenter(MetalVideoView由makeSurface()提供)、SampleBufferPresenter(待實作,§5.6);OffscreenPresenter以@_spi(Benchmark)開放,僅供 Benchmarks 自行註冊(BENCHMARK §7)。 - Shader ABI:
Shaders/Include/Flow/(FlowShader.h:紋理/buffer 索引與DisplayUniforms,使用者 shader 用的FlowFrameInfo待加入;Color.h:轉換函式),供內建 shader 與依賴FlowMetal4的模組共用,建置期編譯為 metallib。使用者.metal也依賴此 ABI,故需維持相容:破壞性變更遞增 major,shader 以//!FLOW <major>宣告,runtime 支援目前與前一個 major。
7.5 範例:GLSL 前端與使用者 Shader(Modules/GLSL、Modules/UserShaders)
FlowGLSL 只做語言前端:GLSL → glslang → SPIR-V → SPIRV-Cross → MSL,產生 GLSLShaderNode: MTL4ShaderNode,與其他 Metal 節點同段執行。新增語言(如 Slang)只需新增對應的前端模組。
FlowUserShaders 讓使用者匯入兩種 shader,共用同一套 //! 指示詞與 hook 語意。
- 格式:
.glsl:mpv/libplacebo GLSL,產生GLSLShaderNode;常見 shader(FSRCNNX、RAVU、Anime4K、ArtCNN、KrigBilateral、SSimSuperRes)免修改即可使用。.metal:MSL,依 Flow Shader ABI(§7.4),產生MSLShaderNode;免轉譯,可用 Metal 專屬功能(SIMD-group、half、threadgroup 調校)。
- 匯入:開檔或拖放
.glsl/.metal,複製到 Application Support;不提供網路下載。啟用清單與順序為 Propertyrender.user-shaders(對應 mpvglsl-shaders,兩種格式可混排),可寫進 Profile。 - 指示詞:
//!HOOK、//!BIND、//!SAVE、//!DESC、//!WIDTH///!HEIGHT、//!OFFSET、//!COMPONENTS、//!WHEN、//!COMPUTE、//!TEXTURE,以及 libplacebo 擴充//!PARAM(顯示為節點參數)、//!BUFFER。.metal另需//!FLOW <major>。 - 編譯:
.glsl:前置 mpv 內建定義(HOOKED_tex、NAME_pos/size/pt/mul/rot/off、frame、random、input_size、target_size、tex_offset等)後交給FlowGLSL。.metal:每個 pass 實作hook()(同 mpv);前置Include/Flow/內容(執行期編譯無法#include)並產生 entry point,依宣告順序綁定HOOKED///!BIND紋理、FlowFrameInfo與參數(按 MSL 對齊打包)。- 非
//!COMPUTE的 pass 包成 kernel,一個 thread 寫一個輸出像素(§6.6「只用 compute」)。 - 兩者皆以
MTL4Compiler背景編譯,以原始碼雜湊快取 MSL 與MTL4Archive;失敗只略過該 shader 並提示。
- 節點化:每個 pass 在每個
//!HOOK目標上各成為一個.display(hook)節點(hook 對應見 §6.4);//!BIND依 §6.4 紋理命名空間解析,綁定不到時略過該 pass(同 mpv);//!SAVE紋理由 runtime 管理並參與 aliasing,與其他節點共用排程、GPU 計時與逾時 bypass。 - 求值:
//!WHEN、//!WIDTH///!HEIGHT的 RPN 運算式在FlowGraph編譯時求值,所依賴的尺寸或參數變化時才重新編譯;frame、random每幀更新。 - 與 mpv 的差異:只掛在 Display Graph;
MAINPRESUB等同MAIN(字幕於overlay合成);OUTPUT在overlay之前,不作用於字幕與 OSD(同 vo_gpu;libplacebo 在之後)。 - 未觸發提示:hook 未觸發的 shader(如來源為 RGB 時的
LUMA、render.scale-light=gamma時的LINEAR)標示於統計 OSD。 - 參考實作:除上列差異外,語意以 libplacebo 的 mpv shader 解析為準(只比對行為,不移植程式碼)。
7.6 範例:VapourSynth Runtime(Modules/VapourSynth)
- Runtime:
VSRuntime只實作FrameRuntime;連續VapourSynthFilterNode經makeSegment組成單一 VS 圖,以自訂來源 filter 把上游FrameSource注入為video_in。 - 幀編號:VS 是隨機存取模型,由
VSRuntime在段內轉換:n為本 generation 起算的輸入序號,總幀數回報極大值;來源 filter 收到getFrame(n)時向上游next()直到第 n 幀,並快取時間窗內的幀,超出快取的倒退請求視為錯誤並 bypass。輸出 PTS 在.passthrough沿用第 n 個輸入幀、.retimed依 §6.5 時間格;reset時重建 VS 圖。 - 節點:
VapourSynthFilterNode: FilterNode,含通用「VapourSynth Script」(使用者.vpy)與選配預設節點(QTGMC 等,宣告參數)。 - UI:新增分頁(腳本編輯、VS/Python 路徑、VS plugin 管理)。
- 隔離:VS、Python、VS plugin 與
.vpy一律在 helper 行程執行(IOSurface 經 XPC 傳遞),框架不介入;disable-library-validation只授予 helper,主程式維持 library validation,Python 崩潰也不影響播放。 - 成本:VS 只接受 planar 格式且無外部記憶體 import,IOSurface 與
VSFrame間需轉換與複製;每個跨 runtime 邊界的成本顯示於統計 OSD,連續 VS 節點應排在一起。
8. Command/Property 系統
整個播放器的「公開介面」,UI 與模組使用同一套,確保模組能做到內建 UI 能做的任何事。
- Property:階層式、型別化、可觀察。例如
playback.position、playback.rate、video.params.colorspace、render.nodes、render.governor、render.gpu-budget、video.rotate、video.aspect、audio.volume、audio.mute、audio.delay、sub.track、video.sync、display.refresh-rate/display.max-refresh-rate(唯讀)。 - Command:具名、參數型別化、可非同步。例如
load、seek、node.enable、screenshot、sub.add。 - Event:
file-loaded、seek、end-file、track-switched、render-stats等。 - 對接:
- SwiftUI → 以
PlayerViewModel投影 Property。 - 按鍵綁定 →
input.conf風格的對應表;可手改檔案或於設定視窗編輯,兩者讀寫同一份檔案。 - App Intents/捷徑/AppleScript → 包裝常用 Command。
- AI agent → MCP tools(
FlowMCP)包裝 Command 與 Property。 - 模組 → 經
ModuleContext.player直接使用。
- SwiftUI → 以
設定與 Profiles
設定分層合併:內建預設 < 使用者設定 < 條件式 Profile < 單檔覆寫 < 執行期。
條件式 Profile 範例:video.hdr == true && display.edrHeadroom < 2 時自動套用較激進的 tone mapping。
9. 字幕
核心定義 SubtitleDecoder(packet → SubtitleEvent)與 SubtitleRenderer(SubtitleEvent → OverlayFrame:IOSurface + 位置),並負責字幕軌選擇、延遲與時間對齊。
public enum SubtitleEvent: Sendable {
case ass(ASSEvent) // 文字字幕一律轉為 ASS
case bitmap(BitmapSubtitle)
}
public struct ASSEvent: Sendable {
public let pts: MediaTime
public let duration: MediaTime? // nil = 直到下一個事件
/// MKV 格式事件行(ReadOrder,Layer,Style,Name,MarginL,MarginR,MarginV,Effect,Text):
/// 即 libavcodec 的輸出與 `ass_process_chunk` 的輸入,兩端免轉換
public let line: String
}
public struct BitmapSubtitle: Sendable {
public let pts: MediaTime
public let duration: MediaTime? // nil = 直到下一個事件(PGS 一律如此)
public let canvasSize: PixelSize // 座標基準(PGS/VobSub 畫面尺寸)
public let rects: [BitmapRect] // 位置、尺寸、stride、premultiplied BGRA;空 = 清除
}
public protocol SubtitleDecoder: AnyObject {
/// 文字字幕軌的 ASS header(styles 等);點陣字幕為 nil
var assHeader: String? { get }
func decode(_ packet: Packet) throws -> [SubtitleEvent]
func flush()
}
public protocol SubtitleRendererFactory: Sendable {
var id: String { get }
func support(for kind: SubtitleKind) -> Bool // .ass / .bitmap
func make(assHeader: String?, attachments: [Attachment]) throws -> any SubtitleRenderer
}
public protocol SubtitleRenderer: AnyObject {
func add(_ event: SubtitleEvent)
func flush()
/// Subtitle 執行緒;內容未變時回傳前一個結果
func render(at time: MediaTime, target: OverlayTarget) -> OverlayFrame?
}| 職責 | 實作 | 範圍 |
|---|---|---|
| 解碼 | FlowFFmpeg(libavcodec) | 文字:SRT、WebVTT、ASS/SSA、mov_text 等 → ass;點陣:PGS、VobSub、DVB、XSUB → bitmap |
| 渲染 ASS | FlowLibass | libass 輸出 glyph bitmap → 合成為單一 RGBA IOSurface;完整支援特效與內嵌字型 |
| 渲染點陣 | FlowFFmpeg | RGBA → IOSurface |
FlowEngine依事件種類選用SubtitleRendererFactory;FlowLibass只依賴FlowInterfaces,不依賴 FFmpeg。- 執行緒:解碼與渲染皆在 Subtitle 執行緒(§5.1);libass 遇複雜特效單次光柵化可達數十 ms,故 Render 執行緒只依顯示 PTS 取用已備妥的
OverlayFrame。 OverlayTarget:輸出像素尺寸、影片於輸出中的位置、Retina 倍率;供 libass 設定畫面尺寸與邊界。尺寸變更時重新光柵化,完成前沿用舊 overlay 縮放顯示。- 重新光柵化:只在內容改變時進行(libass 以
ass_render_frame的變更旗標判斷)。DisplayRuntime在overlayhook 取樣 overlay 紋理合成(§6.6「收尾」)。 - 內嵌字型:
Demuxer.attachments經make交給 libass(ass_add_font)。 - 外部字幕檔:同樣經
Demuxer(libavformat)讀取,開檔時一次讀完並解碼。 - HDR 內容下字幕亮度以可設定的 nits 值(預設 203 nits,依 BT.2408)合成,避免刺眼。
- 支援雙字幕(兩個
SubtitleRenderer實例)、字幕延遲調整、外部字幕自動比對、模組擴充字幕來源。
10. 平台整合
- 視窗:多視窗、原生全螢幕、浮動視窗、Spaces。
- Now Playing/媒體鍵:
MPNowPlayingInfoCenter、MPRemoteCommandCenter。 - PiP:
SampleBufferPresenter(FlowMetal4)。 - 檔案存取:一般 bookmark(記住最近檔案;不啟用 App Sandbox)。
- 網路來源:SMB/WebDAV/HTTP 為
MediaSource(FlowNetwork);HLS 由 demuxer 處理(§5.2)。SMB 以libsmb2在行程內實作,讀取可逾時與取消;不掛載(NetFS的核心讀取無法中斷),不用libsmbclient(GPLv3)。 - 自動化:App Intents、AppleScript、URL scheme、MCP。
- MCP:
FlowMCP於http://127.0.0.1:17996/mcp提供 Streamable HTTP(不開 SSE 串流、不用 session),檢查 Host 與 Origin 以防 DNS rebinding;不另做 stdio helper,App 須先開啟。使用方式見MCP.md。 - 外接顯示:多顯示器、依顯示器 EDR 能力切換管線。
- 關於:顯示
LICENSE與THIRD_PARTY_NOTICES.md(建置時複製進 App bundle)。
11. 可觀測性與測試
11.1 可觀測性
os.Logger:依模組分 subsystem/category。OSSignposter:demux、decode、每個 pass、present 都有 interval,可直接在 Instruments 分析。- 統計 OSD(類似 mpv 的
i鍵):選用的實作、色彩參數、HDR metadata、同步模式、音訊重取樣偏移、降級層級、Governor 品質層級、丟幀/延遲幀、A/V 漂移、各節點 GPU 時間、跨 runtime 邊界成本。
11.2 測試策略
- 單元測試:時鐘(以虛擬時鐘模擬漂移、換裝置、underrun)、佇列、狀態機、模組註冊與撤銷、Property 系統、Governor(以模擬耗時驗證遲滯)。
- 核心整合測試:
FlowEngine/FlowGraph以 mock 模組跑完整生命週期(選擇、fallback、seek、bypass),不需 GPU 與 vendored 函式庫。Mock 統一放在FlowTestSupport,不在各測試 target 複製。 - 模組測試:只依賴自身與
FlowTestSupport,不依賴其他模組與控制平面(註冊以TestModuleContext驗證);需要真實封包時以系統框架讀 fixture(如 VideoToolbox 測試用 AVFoundation passthrough)。跨模組路徑由IntegrationTests負責。 - 邊界檢查:SwiftLint custom rule(
core_banned_import、core_banned_link)確認Packages/Core未匯入 GPU/解碼 API 與 vendored C 函式庫(§4)。 - Golden image:每個內建節點與色彩轉換以參考畫面比對(容許 ΔE/PSNR 閾值);常見 mpv shader 與 mpv 輸出比對。
- 一致性測試語料:涵蓋各容器、編碼、HDR 格式、字幕格式的測試影片集(含邊界案例:VFR、損壞檔、超長 GOP)。
- 效能測試:見
BENCHMARK.md。 - 幀時間測試:以 display link 時間戳驗證 pacing:24p@120Hz 每幀恰好 5 個 vsync;23.976p 補幀到 120@120Hz 每幀 1 個 vsync、音訊重取樣約 0.1%;25p@60Hz 降為
audio;負載不足時依序降級且不在模式間來回切換。 - Fuzzing:Demux 與字幕解析以 libFuzzer 針對畸形輸入。
12. 關鍵架構決策(ADR 摘要)
| # | 決策 | 替代方案 | 理由 |
|---|---|---|---|
| 1 | 自建播放管線(FFmpeg demux + VT decode + Metal) | 純 AVFoundation | AVFoundation 不支援 MKV/多數字幕/逐幀 GPU 處理管線;自建才能做到完整影像處理與擴充 |
| 2 | YUV→RGB 由 Graph 處理,不交給解碼器 | VT 輸出 BGRA | 控制色彩精度、chroma 升取樣品質,並讓 Frame Graph 可存取原生 YUV |
| 3 | 預設 display-resample,條件不符退回音訊時鐘 | 只用音訊時鐘/只用視訊時鐘 | 刷新率為幀率整數倍時以微量重取樣換得無 judder 的 pacing(補幀尤其需要);否則音訊連續優先,因人耳對不連續遠比對丟/重複幀敏感 |
| 4 | Frame Graph(自由排序)+ Display Graph(固定 hook) | 單一固定 hook 圖/單一自由 DAG | 與顯示無關的處理可任意排序並混用 runtime;依賴顯示參數的步驟固定、語意可預期 |
| 5 | 模組僅第一方、靜態連結 | 外部 bundle/ExtensionKit + XPC/Script | 完整 SwiftUI、直接呼叫 API、熱路徑零開銷;免除外部程式碼的簽章、信任與崩潰歸因問題 |
| 6 | 資料平面不使用 actor | 全面 Swift Concurrency | 熱路徑需要可預測延遲,避免 executor 排程與 suspension 的抖動 |
| 7 | LGPL 元件(FFmpeg、FriBidi、libsmb2)動態連結 | 靜態連結/GPL 組態 | 符合 LGPL 重新連結要求,與 Apache-2.0 本體相容 |
| 8 | 核心只含介面與控制平面,所有實作皆為模組 | 核心內建預設實作 | 實作可替換、可擴充;核心能以 mock 測試;官方實作與擴充走同一套介面 |
| 9 | 使用者 shader 採 mpv 指示詞格式,本體可為 GLSL 或 MSL | 自訂格式/僅 GLSL/僅 MSL | GLSL 沿用 mpv 生態現成 shader,MSL 免轉譯且可用 Metal 專屬功能;兩者共用 hook 語意與解析器 |
| 10 | 節點/runtime/語言前端分離 | 每種語言自帶執行環境 | 不同語言的節點同段執行,共用 command buffer、aliasing 與計時;新語言只需前端 |
| 11 | 佇列、執行緒、時鐘歸核心 | 由各實作自行管理 | 生命週期與即時規則可由核心保證,實作不必重複造輪子 |
| 12 | 核心不抽象 GPU API | 核心定義跨 API 的紋理/編碼器 | 抽象必然洩漏且破壞 zero-copy;段間只傳 VideoFrame,段內用 runtime 原生型別 |
| 13 | 本體採 Apache-2.0 | GPL/MPL-2.0/閉源 | 與所有依賴相容、含貢獻者專利授權 |
| 14 | 僅以 GitHub Release 與 Homebrew Cask 發行 | Mac App Store | 免 App Sandbox 與審查限制(使用者 shader、VapourSynth、任意檔案存取);以 Developer ID + notarization 保障 Gatekeeper |
| 15 | Frame Graph 依 PTS 串流 pull | 依幀編號隨機存取(VapourSynth 模型) | 解碼器為 push、VFR/直播總幀數未知、seek 後幀編號無定義;隨機存取只在需要的 runtime 內轉換 |
| 16 | 逐像素步驟合併成單一 pass,只在取樣前切開 | 每個步驟或 hook 一個 pass | 中間紋理讀寫是頻寬瓶頸,運算量不變;與 mpv/libplacebo 相同 |
| 17 | GPU 工作只用 compute | render pass/兩者並用 | 驅動每行程 context 只付一種且最小(§6.6);單一路徑;render 專屬功能皆有 compute 替代 |
13. 主要風險
| 風險 | 影響 | 緩解 |
|---|---|---|
| Dolby Vision 正確性 | 色彩錯誤(P5 綠紫色偏) | 早期建立 DV 測試語料;依規格自行實作,以 libplacebo 輸出比對;以 golden image 驗證 |
| 節點或使用者 shader 拖垮效能或 GPU hang | 卡頓/丟幀 | GPU 時間監控、command buffer 逾時偵測後 bypass 該節點;每個模組與 shader 的 signpost 與統計 |
| 介面邊界劃分不當 | 實作被迫繞過介面、或介面過度抽象 | 先以兩個以上實作驗證介面(如 VT + FFmpeg、Metal + VS);介面無 ABI 承諾,可隨時重構 |
| mpv shader 相容性 | 輸出與 mpv 不同/無法編譯 | 以 libplacebo 語意為準;常見 shader 以 mpv 輸出做 golden image 回歸 |
| 藍牙/AirPlay 輸出延遲回報不準 | A/V 失步 | 延遲隨裝置變更重新查詢;提供 audio.delay 手動補償;評估以 AVSampleBufferAudioRenderer + AVSampleBufferRenderSynchronizer 實作另一個 AudioOutput |
| FFmpeg 安全性(畸形檔案) | 崩潰/漏洞 | Fuzzing、跟進上游安全修正、精簡啟用的 demuxer/decoder |
| 授權違規(依賴、模型、內建 shader) | 無法散布 | 新增前依 THIRD_PARTY_NOTICES.md 審查;禁止 GPL、AGPL、非商用;內建 shader 與移植程式碼限寬鬆授權 |