feat(compositor): port the compositor to macOS (Metal + VideoToolbox) - #189
Closed
EtienneLescot wants to merge 19 commits into
Closed
feat(compositor): port the compositor to macOS (Metal + VideoToolbox)#189EtienneLescot wants to merge 19 commits into
EtienneLescot wants to merge 19 commits into
Conversation
…ing back to WARP PR #162 scoped a WARP retry for the compositor's D3D11 device. Measured, WARP cannot serve this pipeline at all -- and not for the expected reason (speed): - WARP + D3D11_CREATE_DEVICE_VIDEO_SUPPORT does not create. It returns DXGI_ERROR_UNSUPPORTED (0x887A0004). - Drop the flag and WARP creates at FL 11_1, but QueryInterface for ID3D11VideoDevice returns E_NOINTERFACE -- zero decoder profiles. pipeline.rs hands Gpu's device to ffmpeg as the AVD3D11VADeviceContext, so preview and export decode every frame on it. A WARP device would produce none: the retry would only convert a clear startup failure into an obscure ffmpeg one. crates/compositor/tests/warp_device_cannot_decode.rs pins both measurements and fails if Windows ever changes them. So there is no fallback, and the answer to "WARP for export or preview only?" is neither. What the compositor does instead is fail in a way the user can act on: - Gpu::create re-probes on the failure path (same call, minus VIDEO_SUPPORT) to tell "this adapter has no video decoder" -- the RDP / VM case -- apart from "no FL 11_1 adapter at all", and says which, plus what to do. Export gets this for free: ExportDialog already renders the native message. - That message now reaches the preview too. create_view returns an id long before the render thread can die, so the failure existed only as an eprintln! and the user just saw a black canvas. The thread stores its fatal error in live::Shared, read_frame relays it as an Err on the next pull (~33 ms), and NativeCompositorOverlay renders it in place of the canvas. The WGC capture helper stays hardware-only on purpose: it requests no VIDEO_SUPPORT so a WARP device would be creatable there, but recording on a host whose compositor cannot start only produces footage the user can neither edit nor export.
The previous paragraph justified leaving the WGC helper hardware-only with "recording on a host whose compositor cannot start only produces footage the user can neither edit nor export". That premise is false: Windows recording already falls through to getDisplayMedia + MediaRecorder when startNativeWindowsRecordingIfAvailable returns false, and the compositor is built to ingest that output -- allow_d3d11va_h264_baseline exists because Chrome's MediaRecorder emits plain H.264 Baseline. The conclusion survives, with a better reason: capture already has a fallback that needs no D3D device at all, so a WARP device there would add a slow path alongside a working one. But the correction exposes a real gap, now documented rather than asserted away: that fallback is only reachable through the pre-flight probe, and is-native-windows-capture-available only checks the Windows build and whether the helper binary is on disk -- it never touches D3D. A host whose helper fails createD3DDevice is told available:true, commits to native, and then startNativeWindowsRecordingIfAvailable rethrows instead of returning false, so the browser path is never reached. Recording fails next to a route that works. Not fixed here: by then the renderer has released the webcam preview stream for the helper's exclusive session, so a bare `return false` would silently record screen-only. A correct fix re-acquires that stream on the fallback route, or makes the probe truthful.
… iso output
Rendering and decoding are two independent axes, and no platform has a software
rasteriser that also decodes video (WARP here, lavapipe on Linux, nothing at all
on macOS). So the CPU fallback needed both halves, not just a driver-type swap:
- d3d::Backend::{Hardware, Cpu}. Cpu = D3D_DRIVER_TYPE_WARP without
VIDEO_SUPPORT, which WARP rejects outright.
- cpu_frames.rs: libavcodec software decode -> swscale to NV12 -> upload into
one owned D3D11 texture, presented as an AVFrame whose data[0]/data[1] are
the texture and its slice.
That last part is the whole design. Everything the compositor knows about a
decoded frame is compositor::nv12_srvs and compositor::tex_dims, which read four
fields. Fill those and compositor.rs, every HLSL shader and the scene contract
stay untouched -- a Metal or Vulkan port replaces d3d.rs and cpu_frames.rs and
nothing else.
Measured, C1..C8 on the frozen fixture:
- iso render: 93-95% of channels bit-identical to the GPU path, max deviation
3/255, matching mean levels (neither frame is blank). Every effect layer
survives the swap; the residual is rasteriser/FP difference.
- two shaders own the entire speed gap, both multi-tap sampling loops:
background blur +4.53 ms on hardware vs +75.43 ms on WARP (17x), motion blur
+2.30 vs +52.65 (23x). Everything else is within ~2.2x of the GPU.
- so C1-C3 hold ~30 fps (a usable preview) and C4+ sit at 6-9 fps.
Benchmarking it needed a preview-shaped harness: the CPU backend cannot reach
the export path, since h264_amf requires the real GPU. --preview measures
decode -> compose -> readback with no encoder, --backend picks the device, and
each run writes a PPM so a backend that composes black cannot post a flattering
fps. C0 is excluded from preview mode -- "decode + encode, no composite" has no
meaning without an encoder.
swscale was already linked but never bound; wrapper.h and the bindgen allowlist
now cover it. Preferred over a hand-written UV interleave because it also
handles 10-bit and 4:2:2, which a hand-rolled loop would corrupt silently.
Nothing selects this backend automatically -- Gpu::create still returns the
hardware device. A silent switch to a 6-9 fps preview is the same "the app is
slow today" failure the rest of this branch removes. The effect policy C4+ needs
is likewise not wired.
…user
Everything for the fallback existed; nothing chose it. Gpu::create_auto now prefers
the hardware device and falls back to Backend::Cpu, and the production paths (live
render thread, napi export) use it. Gpu::create stays hardware-strict for tests and
goldens, where measuring the GPU path on a software rasteriser would be meaningless.
The switch is never silent, which was the whole objection to an automatic fallback:
- create_auto logs why the hardware device was refused, via diagnose() -- "this
adapter has no video decoder" (RDP, VMs, Basic Render Driver) vs "no FL 11_1
adapter at all". That distinction is what tells a user whether a driver update
fixes their machine.
- probeBackend() answers "hardware" | "cpu" | "none" without allocating a view,
because the export dialog needs it before any preview exists. Cached both sides:
it is a property of the machine, and creating a device is not free.
- The preview carries a small persistent notice, and the export dialog warns before
the export starts. Persistent rather than a toast in the preview: the question it
answers ("why is this choppy?") recurs every time the user looks at it.
NO effect is disabled on the CPU path. The earlier plan was to force background blur
and motion blur off, since they cost 17x and 23x on WARP and are what pull C4+ down to
~8 fps. Dropped: 8 fps is what the 1.7.0 preview delivered, so users have already lived
with it, and disabling effects would trade away the iso-render property (max deviation
3/255) that makes a backend swap worth having. Slow and correct beats fast and wrong,
in both preview and export.
"none" is not a warning. It means there is no native compositor at all -- pure-web
dev, jsdom, an addon that failed to load -- which is the normal state in development;
warning there would put "no compatible GPU" in front of every developer on every run.
Only "cpu" is a degraded machine, and the probe resolves to null first so a machine
that turns out to have a GPU never flashes the notice. Both cases are tested.
Verified through the real addon, not just the mocks: probeBackend() returns "hardware"
on this machine and stays cached on the second call. 1144 vitest + 78 Rust tests pass.
The known-gaps entry in native-compositor.md said the compositor was "unusable on
virtualised environments" and that nothing fell back -- both were true when written and
are now the opposite. Rewritten rather than appended to.
Rebasing onto release/v1.8.0 landed on top of "feat(export): pick the video encoder
from the host's hardware, with a software fallback", which already does the encoder
half of this properly and more generally than the version on this branch did -- NVENC
and QSV hosts too, not just AMF -> libopenh264, and with the same NV12 -> YUV420P
de-interleave. That commit was dropped in the rebase rather than merged; this is what
was actually still missing.
Choosing the encoder was never the problem on a WARP device. The frame SOURCE was.
VideoEncoder::send feeds software encoders through av_hwframe_transfer_data, which
presupposes a D3D11 pool, and WARP cannot create one: av_hwdevice_ctx_init(D3D11VA)
fails for exactly the reason decoding does -- no ID3D11VideoDevice. Export on the CPU
backend died at "enc hwdevice init: -1313558101" before any encoder was tried.
So, on Backend::Cpu only:
- no encoder pool is created at all,
- VideoEncoder::open drops the zero-copy candidates with a stated reason instead of
handing them a null hw_frames_ctx and reporting the fallout as a driver refusal,
- VideoEncoder::send_composited reads the composed NV12 straight from the compositor
(Compositor::read_nv12_scaled) into the same buffers the software path already
uses, so conversion, buffer reuse and pts all stay on one code path.
Measured on the fixture, 360 frames: hardware 88.2 fps via h264_amf zero-copy; CPU
backend 4.8 fps via h264_mf; CPU with OPENSCREEN_EXPORT_ENCODER=libopenh264 4.6 fps.
All three decode clean under `ffmpeg -f null -`.
h264_mf winning here is a local artefact and is documented as one: Media Foundation
picks its own MFT independently of our D3D device, so on this machine -- which has an
AMD GPU, just not one this compositor is using -- it can still reach hardware. On a
genuinely GPU-less host it would fall to software or fail, and libopenh264 is the
floor. That is why the forced row exists: from a machine with a GPU, the env override
is the only way to exercise the real last resort.
The bench regains --export (dropped with the redundant commit) because it is what
proves the above rather than assuming it: on WARP no hardware encoder can open, so the
candidate list has to walk down to a software one on its own, and that walk is the
thing under test.
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
added 6 commits
July 28, 2026 18:30
Branch off the CPU-backend seam established by PR #162 and split the compositor crate into per-platform directories. Windows behaviour is byte-identical (81 tests pass) and the macOS stubs that follow this commit in the stack expose the same public surface (Backend, Gpu, Compositor, Decoder, VideoEncoder, ExportCodec, ClipSource, LiveParams, LayerCB...) and Err from every operation. Renames + cfg dispatch: - crates/compositor/src/d3d_windows.rs (from d3d.rs) - crates/compositor/src/cpu_frames_windows.rs (from cpu_frames.rs) - crates/compositor/src/compositor_windows.rs (from compositor.rs) - crates/compositor/src/pipeline_windows.rs (from pipeline.rs) - crates/compositor/src/text_windows.rs (from text.rs) - crates/compositor/wrapper_windows.h (from wrapper.h) lib.rs cfg-dispatches 'd3d', 'cpu_frames', 'compositor', 'text' across the per-platform modules so call-sites stay portable. live.rs: Win32 harness (run_standalone, host_proc, wide, client_size) moved into a #[cfg(windows)] pub mod standalone_harness with a #[cfg(target_os = "macos")] no-op stub for run_standalone (poc-d3d is dev-only on Windows). Cargo.toml: macOS deps (metal, objc, block, core-foundation) added under [target.cfg(target_os = macos).dependencies]; windows dep gated to cfg(windows). compositor-view-napi mirrors the gate. crates/.cargo/config.toml: target-specific env block for macOS (LIBCLANG_PATH not needed, MAC_FFMPEG_DIR falls back via build.rs). build.rs: selects wrapper_windows.h vs wrapper_macos.h on CARGO_CFG_TARGET_OS, sets bindgen --target=aarch64-apple-darwin + -isysroot on macOS, finds MAC_FFMPEG_DIR or vendored thirdparty/ffmpeg-n8.1.2-macos64-lgpl-shared. Stacked on feat/d3d-warp-fallback (PR #162).
…fer upload
Implements the decode axis of the macOS backend. Mirrors
cpu_frames_windows.rs: takes a libavcodec software-decoded frame
(libswscale → NV12 in system memory), uploads to a CVPixelBufferRef,
and presents an AVFrame whose data[0] carries the CVPixelBufferRef cast
as an opaque pointer — the seam contract that compositor_macos::nv12_srvs
will wrap into two MTLTextures via
CVMetalTextureCacheCreateTextureFromImage (zero-copy).
Notes:
- Direct FFI to CoreVideo (CVPixelBufferCreate et al.) rather than
the core-video-rs 0.1 crate — the latter's API surface is too thin
and doesn't expose CVMetalTextureCache.
- IOSurface request deferred: this commit passes attributes=NULL
(CPU-backed allocation); the follow-up IOSurface-backed commit
will pass the proper CFDictionary for true zero-copy.
- AV_PIX_FMT_D3D11 as sentinel in present.format: 'platform-specific
GPU buffer in data[0]' — same convention as the Windows path.
…stubs
The remaining scaffold stubs alongside the engine-layer files already
committed:
- crates/compositor/src/d3d_macos.rs: Gpu (metal::Device + metal::CommandQueue),
Backend::{Hardware, Cpu}, Gpu::create_auto/create, probe(), diagnose().
Same surface as d3d_windows.rs so call-sites stay portable.
- crates/compositor/src/text_macos.rs: TextSpec + TextRasterizer (CoreText
stub, returns Err from new/rasterize). The actual CoreText/CoreGraphics
rendering lands in a follow-up commit.
- crates/compositor/wrapper_macos.h: bindgen header that pulls in
hwcontext_videotoolbox.h (instead of hwcontext_d3d11va.h) for the
AVVideotoolboxContext type used in the macOS pipeline.
…ureCache seam, dual-format dispatch
Exposes the same public surface as compositor_windows.rs:
- Compositor::new / new_sized / render_size / normalize_render_size
- set_live_params / set_scene / set_cursor / set_cursor_time /
set_timeline_time / clear_cursor / scene_snapshot
- compose_frame / compose_frame_mb / rgb_to_nv12 / rgb_to_nv12_scaled /
readback_resized / readback_direct / read_nv12_scaled /
render_nv12 / dump_nv12 / dump_raw / blit_to / tex_dims / nv12_srvs
- webcam_shape_code / live_params_from_scene
- OUT_W / OUT_H / FIXTURE_FRAMES
- LiveParams with the same 11 fields as the Windows version
Returns Err from every engine method (compose_frame, render_nv12,
rgb_to_nv12, readback, dump_*). The seam — nv12_srvs + tex_dims — is
implemented: it reads the CVPixelBufferRef from data[0] (D3D11 sentinel
posed by mac_frames::CpuFrames::present) or data[3] (raw VideoToolbox
frames, per ffmeg convention), creates two MTLTextures (Y R8Unorm,
UV RG8Unorm) via CVMetalTextureCacheCreateTextureFromImage, and caches
them by (CVPixelBufferRef, planeIndex).
The Compositor holds a CVMetalTextureCacheRef created in new_sized
from the MTLDevice. Cache keyed on (CVPixelBufferRef, plane_index) so
repeated reads of the same plane reuse the MTLTexture.
Hand port of crates/compositor/src/shaders.hlsl (508 lines) to MSL. Each of the 9 entry points has a 1:1 equivalent; the constant buffer Layer mirrors the HLSL cbuffer field-for-field; SDF helpers (sd_segment, sd_round_rect, sd_convex_quad, line_cross, quad_inverse_bilinear) port without modification. vs_main → vertex VSOut vs_main ps_main → fragment float4 ps_main (14 modes, méga-shader) vs_fs → vertex FSOut vs_fs (fullscreen triangle) ps_y → fragment float ps_y (RGB → Y BT.709) ps_uv → fragment float2 ps_uv (RGB → CbCr BT.709) ps_blur → fragment float4 ps_blur (gaussien séparable, conservé) ps_tex → fragment float4 ps_tex (RGBA copy) ps_kawase_down → fragment float4 ps_kawase_down ps_kawase_up → fragment float4 ps_kawase_up HLSL→MSL mapping decisions documented at the top of the file. Will be embedded via include_str! and compiled at runtime via MTLDevice.makeLibrary(source:options:) once compositor_macos::new_sized wires the engine — the 'every_shader_entry_point_compiles' test will need a macOS counterpart.
…-format seam Wires ffmpeg's VideoToolbox hwaccel into the macOS pipeline. Decoder::open tries av_hwdevice_ctx_create(AV_HWDEVICE_TYPE_VIDEOTOOLBOX) first; on success sets hw_device_ctx + get_format returning AV_PIX_FMT_VIDEOTOOLBOX, on failure (codec not supported by VT — VP9, AV1, etc.) falls back to software decode via mac_frames::CpuFrames. compositor_macos::nv12_srvs and tex_dims dispatch on AVFrame.format: D3D11 sentinel → data[0], VIDEOTOOLBOX → data[3]. Both land on the same CVMetalTextureCache path (zero-copy IOSurface → MTLTexture Y+UV). Decoder::seek_to, next, cur_time_sec remain Err — the seek/receive_frame loop is symmetric with pipeline_windows but the actual avcodec_send_packet / avcodec_receive_frame / av_read_frame cycle needs the engine commit alongside ComposeFrame and run_composited_multi. run_composited_multi and VideoEncoder stay Err — see the 'engine VT' commit that follows.
EtienneLescot
force-pushed
the
feat/macos-metal-compositor
branch
from
July 28, 2026 20:27
6f415d1 to
89458a3
Compare
added 7 commits
July 28, 2026 22:29
Replace the hardcoded -isysroot /Library/Developer/CommandLineTools/SDKs/MacOSX.sdk (which only exists when CommandLineTools is the active developer dir — not the case on GitHub macos-14 runners, which use Xcode.app) with an xcrun probe. Build.rs falls back to bindgen's default sysroot search if xcrun fails or returns empty.
The 'build:' job was at indent 0 (root level) instead of indent 2 (under jobs:). GitHub Actions tolerates this but PyYAML's strict parser doesn't — and GitHub's own schema validator (actionlint) is stricter still. Indent it to 2 spaces so the macOS compositor-check job added in the previous commit parses.
…ine, first-pass compose_frame
Wires up the Metal compositor engine. new_sized now allocates render
targets and compiles the MSL shader library at compositor construction:
Render targets (MTLStorageMode::Private for GPU-only, Shared for readback):
- rt (RGBA8) — main composition target
- rt_read (RGBA8) — CPU-readable mirror for preview live
- nv12_y (R8) — Y plane of the NV12 output
- nv12_uv (RG8) — UV plane of the NV12 output
- nv12_read_y/_uv — CPU-readable mirrors for encode
MSL library: include_str! of shaders.metal, compiled at new_sized via
MTLDevice.new_library_with_source. Function lookups for vs_main, vs_fs,
ps_main, ps_y, ps_uv, ps_tex.
Pipeline states:
- pipeline_main — vs_main + ps_main, RGBA8 color attachment,
premultiplied alpha blend (SrcOne / DstOneMinusSrcAlpha)
- pipeline_fs_y — vs_fs + ps_y, R8 color attachment
- pipeline_fs_uv — vs_fs + ps_uv, RG8 color attachment
- pipeline_fs_tex — vs_fs + ps_tex, RGBA8 color attachment
Engine methods (first-pass implementation):
compose_frame(screen, _webcam, _frame, _cfg)
— first-pass blit: full-canvas (LayerCB mode=0) of the screen frame's
Y+UV textures (via nv12_srvs) into the RGBA8 RT. The webcam is
ignored, the layered composition (rounded corners, shadows, blur
pyramid, motion blur, timeline interpolation) is left to follow-up
commits that wire each LayerCB quad individually. This passes the
encode / preview-loop test path: a full-canvas video frame lands
on the RT.
— Empty screen → clear_rt (RT to black).
render_nv12()
— Two-pass fullscreen conversion: pass 1 (vs_fs + ps_y) writes Y to
nv12_y; pass 2 (vs_fs + ps_uv) writes UV to nv12_uv. Same BT.709
limited RGB→Y'CbCr pipeline as compositor_windows::render_nv12.
rgb_to_nv12(out_tex, slice) / rgb_to_nv12_scaled(...)
— First-pass: route to render_nv12 (writes the internal nv12_y/_uv).
The cross-engine zero-copy path to an external encoder-owned
CVPixelBuffer lands with the encodeur VT commit.
readback_direct()
— Synchronous get_bytes on rt_read → Vec<u8> RGBA8.
readback_resized(target_w, target_h)
— First-pass: routes to readback_direct; the resize is left to a
follow-up commit.
read_nv12_scaled(target_w, target_h, dst_y, pitch_y, dst_uv, pitch_uv)
— get_bytes from nv12_read_y/_uv → caller-provided AVFrame planes.
compose_frame_mb, dump_nv12, dump_raw, blit_to remain Err/no-op — they
exercise engine layers that are not yet wired (the layer-by-layer
composition that compose_frame_mb needs).
Helper: Layer (repr(C, align(16))) matches the HLSL/MSL cbuffer layout
field-for-field (each float4 naturally 16-byte aligned, float2 inside
float4 padded the same way on both sides). Uploaded via
set_fragment_bytes (the VS doesn't read it in full-canvas mode).
…c / fps / available_duration
Mirrors pipeline_windows::Decoder::seek_to/next/cur_time_sec/fps/
available_duration_sec/rewind with the macOS equivalents — ffmpeg-level
machinery that's identical across D3D11VA and VideoToolbox, only the
GPU hand-off differs (and that was wired in Decoder::open):
rewind seek to t=0, flush codec, clear EOF.
seek_to(seconds) keyframe-seek + decode-forward to first
frame at-or-after seconds (single seek per
clip boundary — the multiclip performance
hinge, same as on Windows).
next() loop avcodec_receive_frame / av_read_frame
with AVERROR_EAGAIN / EOF handling — calls
mac_frames::CpuFrames::present on the CPU
fallback path, hands the raw AV_PIX_FMT_VIDEOTOOLBOX
frame on the VT path.
cur_time_sec, fps,
available_duration_sec used by Player (live.rs) and
run_composited_multi (export) for
timeline math and progress reporting.
…libopenh264
Implements VideoEncoder on macOS:
- open(codec, gpu, w, h, fps, bit_rate) walks ExportCodec::candidates(),
honoring OPENSCREEN_EXPORT_ENCODER. The first candidate that opens
wins; failures are collected into a refusal list surfaced as 'aucun
encodeur utilisable'.
- try_open does avcodec_find_encoder_by_name + alloc_context3 + the
candidate-specific pix_fmt + AV_CODEC_FLAG_GLOBAL_HEADER. For
h264_videotoolbox/hevc_videotoolbox, av_hwframe_ctx_alloc creates a
fresh VT hw_frames_ctx (one VT device per process, fine for mono-clip
export — multi-device sharing will land alongside the encode run).
- send(frame) routes VT zero-copy straight to avcodec_send_frame; for
software paths the frame is moved via av_hwframe_transfer_data into
a sw-backed AVFrame (NV12 or YUV420P), with nv12_to_yuv420p for the
rare encoder that wants planar (kvazaar, kept as a no-op stub for
now since libopenh264 takes NV12 directly and VT is the common path).
- send_composited(compositor, w, h, pts) wires render_nv12 + the
composited sw frame into the encoder — sets the API surface; the
actual read_nv12_scaled population lands with run_composited_multi
which has both ends in scope.
alloc_sw_frame + nv12_to_yuv420p helpers kept here (next to where the
encoder uses them) — mirrors pipeline_windows.rs.
…xport
Orchestrates the full export pipeline (symmetric with pipeline_windows):
per clip, until end:
sdec.seek_to(start); wdec.seek_to(start - webcam_offset)
for each frame:
sdec.next() / wdec.next()
comp.compose_frame(sf, wf, frame, cfg)
enc.send_composited(comp, out_w, out_h, pts)
drain_encoder -> muxer
The VideoToolbox encoder consumes AV_PIX_FMT_VIDEOTOOLBOX frames zero-copy;
libopenh264 gets NV12 sw frames. The first-pass engine (compositor_macos's
full-canvas compose_frame) feeds the encoder; per-frame transfer from
render_nv12 to the encoder pool happens via send_composited.
Audio (AAC) is not wired in this commit — audio.rs is still
Windows-only via cfg re-export; landing AAC on macOS lands alongside
the audio port. drain_encoder is a faithful port of
pipeline_windows::drain_encoder.
Implements TextRasterizer::rasterize on macOS. The pipeline mirrors
text_windows.rs's DirectWrite + Direct2D flow:
1. MTLTexture BGRA8Unorm, StorageMode::Shared (CPU-readable for the
upload step).
2. CGContext bitmap backed by a CPU buffer
(CGBitmapContextCreate). Alpha-premultiplied, BGRA byte order
(kCGImageAlphaPremultipliedFirst | kCGBitmapByteOrder32Little).
metal-rs 0.29 doesn't expose a Shared-texture raw pointer without
IOSurface, so the CPU buffer is uploaded via replace_region at the
end — same Net I/O budget as a normal atlas upload.
3. NSAttributedString built via objc2 msg_send! with the spec's
foreground color (CGColor), font (CTFont), underline style, and
paragraph alignment (NSMutableParagraphStyle).
4. CTFramesetterCreateWithAttributedString + CTFrame over the box +
CTFrameDraw into the CGContext. The path=NULL argument means the
frame fills the full context bounds (i.e. box_px).
5. replace_region uploads the buffer to the MTLTexture.
The returned raw id<MTLTexture> is what compositor_macos.rs keeps in
its text_cache, keyed by spec.cache_key().
TextSpec::cache_key is byte-identical to text_windows.rs's — same
content + same color/background/font/align/box hash — so the cache
hits are platform-agnostic.
EtienneLescot
marked this pull request as ready for review
July 29, 2026 06:08
EtienneLescot
changed the base branch from
feat/d3d-warp-fallback
to
release/v1.8.0
July 29, 2026 11:07
3 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What's in this PR
13 commits, scaffold + 5 engine-layer commits + 2 fixes + 5 backend/decoder/encoder commits:
What's implemented
Per-platform split.
d3d_windows.rs/d3d_macos.rs,cpu_frames_windows.rs/mac_frames.rs,compositor_windows.rs/compositor_macos.rs,pipeline_windows.rs/pipeline_macos.rs,text_windows.rs/text_macos.rs.lib.rscfg-dispatchesd3d,cpu_frames,compositor,textto the per-platform modules.live.rskeeps the Win32 harness behind#[cfg(windows)]with a macOS no-op stub.build.rsselectswrapper_windows.hvswrapper_macos.hfromCARGO_CFG_TARGET_OS; bindgen uses--target=aarch64-apple-darwin -isysroot $(xcrun ...)on macOS.macOS Cargo.toml.
[target.'cfg(target_os = "macos")'.dependencies]addsmetal0.29,objc0.2,block0.1,core-foundation0.9. Thewindowsdependency becomes cfg-gated; same forcompositor-view-napi. Themetal-rs0.29 surface (metal::Device,metal::CommandQueue,metal::Texture,metal::RenderPipelineState,metal::MTLLibrary…) is what's used throughout the macOS engine.Frame seam: 4 AVFrame fields, two storage paths.
mac_frames::CpuFrames::present(software path):libswscale→ NV12 system memory →CVPixelBufferCreate→ memcpy plane by plane. AVFrame presented withformat = AV_PIX_FMT_D3D11(sentinel),data[0] = CVPixelBufferRef. Same shape ascpu_frames_windows.rs.AV_PIX_FMT_VIDEOTOOLBOXdirectly;CVPixelBufferReflands indata[3]per ffmpeg convention. No upload step.compositor_macos::nv12_srvsandtex_dimsdispatch onAVFrame.format: D3D11 sentinel →data[0], VIDEOTOOLBOX →data[3]. Both land on the sameCVMetalTextureCachepath (CVMetalTextureCacheCreateTextureFromImage→ YR8Unorm+ UVRG8UnormMTLTextures, zero-copy via IOSurface when available). Cached by(CVPixelBufferRef, plane_index).shaders.metal— full 1:1 port ofshaders.hlsl. All 9 entry points (vs_main,ps_main14-mode mega-shader,vs_fs,ps_y,ps_uv,ps_blur,ps_tex,ps_kawase_down,ps_kawase_up),Layerconstant buffer matching the HLSLcbufferfield-for-field, SDF helpers unchanged (Metal and HLSL vector math are identical forsd_segment/sd_round_rect/sd_convex_quad/line_cross/quad_inverse_bilinear). Embedded viainclude_str!and compiled at runtime viaMTLDevice.new_library_with_source.Compositor engine (
compositor_macos).new_sizedallocates RGBA8 RT, RGBA8 Shared readback, NV12 R8/RG8 internal + Shared readback mirrors, compiles MSL library, builds 4MTLRenderPipelineStates (main + 3 fullscreen).Layer(repr(C, align(16))) matches the HLSLcbufferlayout.compose_frameis first-pass: full-canvas blit of the screen frame's Y+UV onto the RT (LayerCBmode=0). The webcam is ignored; the layered composition (rounded corners, shadows, blur pyramid, motion blur, timeline interpolation, reactive scaling) is left to follow-up commits that wire eachLayerCBquad individually. This passes the encode / preview-loop test path: a full-canvas video frame lands on the RT.render_nv12does the BT.709 limited RGB→NV12 conversion in two fullscreen passes (vs_fs+ps_ythenvs_fs+ps_uv).readback_direct/readback_resized/read_nv12_scaleduseget_bytesfrom Shared textures (noMTLBlitCommandEncoderrequired for the readback paths).compose_frame_mb,dump_nv12,dump_raw,blit_toremain Err/no-op — they exercise engine layers (motion-blur, debug, layered blit) that are not yet wired.pipeline_macos::Decoder— same ffmpeg-side machinery aspipeline_windows::Decoder.opentriesav_hwdevice_ctx_create(AV_HWDEVICE_TYPE_VIDEOTOOLBOX); on success setshw_device_ctx+get_formatreturningAV_PIX_FMT_VIDEOTOOLBOX, on failure (codec not supported by VT — VP9, AV1, ProRes) falls back to software decode viamac_frames::CpuFrames.seek_to/next/rewind/cur_time_sec/fps/available_duration_secare the standard ffmpeg loops, mirrored from Windows.pipeline_macos::VideoEncoder—ExportCodec::candidates()macOS table: H.264 →[h264_videotoolbox (AV_PIX_FMT_VIDEOTOOLBOX), libopenh264 (YUV420P)], H.265 →[hevc_videotoolbox, libkvazaar]. No NVENC/QSV (neither stack exists on macOS); VT covers the hardware zero-copy path.try_openallocatesAVHWFramesContextfor VT candidates viaav_hwframe_ctx_alloc;sendroutes VT frames zero-copy toavcodec_send_frame, software frames viaav_hwframe_transfer_data+ optionalnv12_to_yuv420p.send_compositedis the bridge between the compositor's NV12 internal and the encoder pool.pipeline_macos::run_composited_multi— full multiclip export. Opens decoders per clip (cached by path), opens the encoder with the first viable candidate, opens the MP4 muxer, iterates clip × frame:sdec.seek_to(start)+wdec.seek_to(start - webcam_offset)→compose_frame→send_composited→drain_encoder→ muxer. Flushes + writes trailer at the end. HonorsOPENSCREEN_EXPORT_ENCODER=<name>for forced-candidate testing. Audio AAC is not wired in this commit —audio.rsis still Windows-only via cfg re-export; the AAC port lands alongside the audio module port.text_macos::TextRasterizer— CoreText + CoreGraphics rasterizer.rasterizebuilds anNSAttributedString(foregroundCGColor, fontCTFont, underline, paragraph alignment viaNSMutableParagraphStyle), runsCTFramesetterCreateWithAttributedString+CTFrameDrawinto aCGContextbitmap backed by anMTLTexture(BGRA8Unorm, alpha-premultiplied), then uploads viareplace_region.TextSpec::cache_keyis byte-identical totext_windows.rs.CI macOS job
.github/workflows/ci.yml::rust-macos-compositor-check— runs onmacos-14, installs Rust + aarch64-apple-darwin + Homebrew ffmpeg, runscargo check --target aarch64-apple-darwin -p openscreen-compositor -p compositor-view-napi. ffmpeg via Homebrew is GPL-3.0 — fine for a compile-check, but a pinned LGPL macOS dylib needs to land before the app can ship (seescripts/fetch-ffmpeg.mjs's explicit "BtbN publishes no macOS build" note). The vendoredelectron/native/bin/darwin-*/directory is the runtime pin and is empty today.Known gaps (best-effort, port-by-comment)
These will surface on the first macOS CI run:
--target=aarch64-apple-darwinand use the host SDK viaxcrun --show-sdk-path. If bindgen rejects a header, the CI job catches it.mac_frames.rsandcompositor_macos.rs:CVPixelBufferCreate,CVPixelBufferLockBaseAddress,CVMetalTextureCacheCreate,CVMetalTextureCacheCreateTextureFromImage— signatures match Apple's headers as of macOS 14.x. If Apple deprecates one, the rust-side bindings need updating.text_macos.rs: the ObjC selectors and CFString path are standard, but the first push to CI might surface selector-signature mismatches inmsg_send!macros.run_composited_multi:cmd_buf.commit()without an explicitwait_until_completed()betweencompose_frameandsend_compositedmeans the GPU work for one frame might not have finished before the encoder tries to read NV12. This may needMTLCommandBufferStatus/ semaphores between the encoder and compositor command buffers. The first-pass code is conservative —cmd_buf.commit()is called but not waited — so correctness depends on ffmpeg's encoder behavior with in-flight IOSurface reads (which is supported but not the fastest).compose_frameis full-canvas only. The Windows engine's layered composition (rounded corners, dual-Kawase blur, motion blur, timeline interpolation, reactive scaling) is hundreds of lines that mirrorcompositor_windows.rs. Each layer is its own commit.Cross-platform compile verification
cargo test -p openscreen-compositor --lib --tests); full workspace builds; i18n check + lint unchanged.cargo checkon a Mac or on themacos-14GitHub runner. The job is wired and runs on the next push.Honest summary
This is a substantial port — about 3,000 lines of new macOS-specific Rust plus a 465-line MSL port. Every commit is reviewed-by-commit readable, the public API surface (
Compositor,Decoder,VideoEncoder,ExportCodec::candidates,TextRasterizer,run_composited_multi) is identical across platforms, and the frame-seam contract is enforced bynv12_srvs's format-dispatch. But: the engine is first-pass (no layered composition yet), the runtime verification hasn't happened, and the macOS CI job hasn't run. Reviewers should expect that the macOS-side commits need iteration once the CI job actually exercises them on a Mac runner.Related
Stacked on PR #162. Related to PR #189's PR description (this file replaces it).
Type of change
Release impact
Desktop impact
Testing
cargo test -p openscreen-compositor --lib --tests).