makepad/platform/video/src/apple_decoder.rs
Admin 5dc8ef5256 platform: a remote control surface, streaming video codecs, and float render targets
An app built with `--remote` now serves a localhost HTTP control surface:
window list, per-window PNG grabs, real mouse/key/text injection, widget
rects, a log ring buffer, and `/gq` (grab every window, then quit). It exists
so a test or an agent can DRIVE a running app instead of reasoning about it
from source — the protocol is documented in AGENTS.md. Grabs are targeted per
window (`/g?w=N`), so a multi-window app is captured window by window rather
than whichever pass happens to present first, and `log!` mirrors into the ring
buffer without anyone owning the app's stdout.

platform/video grows a streaming half beside the file half. StreamEncoder /
StreamDecoder with Apple VideoToolbox and Windows Media Foundation backends,
Annex-B framing, and all-intra bound through pEncodingParameters on Windows —
the only control that MFT actually honors, as the readbacks claim success for
everything else. The file decoder can now be asked for a SPECIFIC frame rather
than only the next one, which is what frame-exact seek and bounce playback
need. Tests cover file seek and the stream round trip.

Draw shaders gain `Rgba16F` and `Rgba32F` color formats to pair with the
float render textures: blending off, whole-texel writes, meant for GPU
simulation state (particle position/velocity, fluid fields) rather than
pictures.

Windowing and dialogs:
  - `CxOsOp::SetChromelessWhenMaximized` drops the native maximized border
    strip on Windows, so a maximized window reads as a clean picture.
  - `Cx::open_select_folder_dialog` opens the native folder picker with a
    title and start location, answered by a `FileDialogAction` in the actions
    pass; cancelling is a first-class outcome, not an error.
  - Windows reports a user close the way macos.rs already did.
  - macOS swaps the titlebar container so WindowDragQuery alone decides window
    drags, and the delegates carry a panic shield.
  - `Windows::id_iter()` enumerates window slots generation-correctly.

Headless: the virtual GPU and its rasterizer are substantially rebuilt around
the shader runtime preamble, making `MAKEPAD=headless` render-to-PNG a real
test surface rather than a smoke check. `PerfMonitor::frames_painted()` lets a
scripted driver pace itself to PRESENTED frames instead of queueing passes
faster than the GPU retires them.
2026-08-23 00:43:20 +02:00

604 lines
25 KiB
Rust

//! macOS AVFoundation backend for the video FILE decoder seam.
//!
//! AVAssetReader based: an mp4 path in, decoded NV12 frames + 16-bit PCM
//! audio out. AVAssetReaderTrackOutput decodes through VideoToolbox, which
//! uses the hardware decode engine on Apple Silicon for both H.264 and
//! H.265. Output is requested as kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange
//! (NV12), matching the facade's frame type; CoreVideo hands back buffers at
//! display size (codec padding lives past the row stride), so the repack to a
//! tight buffer doubles as the aperture crop.
use {
crate::{
DecodedAudioChunk, DecodedVideoFrame, VideoFileCodec, VideoFileError, VideoFileInfo,
},
makepad_apple_sys::*,
std::ffi::c_void,
std::ptr::NonNull,
};
const HNS_PER_SECOND: i128 = 10_000_000;
/// RAII NSAutoreleasePool: decode pulls run on plain Rust threads which have
/// no ambient pool, and AVFoundation autoreleases freely internally.
pub(crate) struct AutoreleasePool(ObjcId);
impl AutoreleasePool {
pub(crate) fn new() -> Self {
unsafe { Self(msg_send![class!(NSAutoreleasePool), new]) }
}
}
impl Drop for AutoreleasePool {
fn drop(&mut self) {
unsafe {
let _: () = msg_send![self.0, release];
}
}
}
pub(crate) fn cmtime_to_100ns(time: CMTime) -> i64 {
if time.flags & kCMTimeFlags_Valid == 0 || time.timescale <= 0 {
return 0;
}
(time.value as i128 * HNS_PER_SECOND / time.timescale as i128) as i64
}
/// Extract "<localizedDescription> (os error <code>)" from an NSError.
pub(crate) fn nserror_to_video_error(context: &str, error: ObjcId) -> VideoFileError {
if error == nil {
return VideoFileError::new(context.to_string());
}
unsafe {
let desc: ObjcId = msg_send![error, localizedDescription];
let code: isize = msg_send![error, code];
let msg = if desc != nil {
format!("{}: {}", context, nsstring_to_string(desc))
} else {
context.to_string()
};
VideoFileError::with_code(msg, code as i32)
}
}
fn gcd(mut a: u32, mut b: u32) -> u32 {
while b != 0 {
let t = a % b;
a = b;
b = t;
}
a.max(1)
}
/// AVAssetReaderStatus values.
const READER_STATUS_FAILED: i64 = 3;
/// 100ns ticks per second, as a CMTime timescale.
const HNS_TIMESCALE: i32 = 10_000_000;
/// How far before the seek target the reader's time range actually starts.
///
/// MEASURED: an AVAssetReader whose `timeRange` begins mid-frame does not drop
/// the frame it lands inside — it TRIMS it and rewrites its presentation
/// timestamp to the range start. Ask for the target exactly and the frame
/// before it comes back wearing the target's timestamp, so a pts-based discard
/// loop cannot tell it apart from the frame actually wanted. Seeking to
/// frame 1 of a 24 fps clip returned frame 0 at frame 1's pts, because
/// 1/24 s is 416666.67 ticks and the pts the decoder reports for it truncates
/// to 416666 — a hair inside frame 0.
///
/// Starting 100 µs early makes the trimmed straddler carry a timestamp
/// strictly below the target, so the loop drops it and the first frame kept is
/// the real one. 100 µs is far below the shortest plausible frame (1/240 s =
/// 41666 ticks), so backing off can never skip a frame, and far above the
/// sub-tick rounding it exists to absorb.
const SEEK_BACKOFF_100NS: i64 = 1_000;
pub struct MacosVideoFileDecoder {
// The asset and its tracks outlive any single reader: an AVAssetReader
// cannot rewind, so `seek` throws its reader away and builds a new one
// over these.
asset: RcObjcId,
video_track: RcObjcId,
audio_track: Option<RcObjcId>,
reader: RcObjcId,
video_output: RcObjcId,
audio_output: Option<RcObjcId>,
info: VideoFileInfo,
video_eos: bool,
audio_eos: bool,
}
/// One reader and the outputs attached to it. Rebuilt wholesale on seek.
struct ReaderSet {
reader: RcObjcId,
video_output: RcObjcId,
audio_output: Option<RcObjcId>,
}
/// Build an AVAssetReader over `asset` reading `video_track` as NV12 and
/// `audio_track` (when present) as 16-bit interleaved PCM, started.
///
/// `start_100ns: Some(t)` sets the reader's `timeRange` to `[t, +inf)`, which
/// is the only way AVFoundation offers to begin reading anywhere but zero —
/// AVAssetReader has no seek, and restarting one from the top and discarding
/// forward would make a late seek cost the whole file. The reader still lands
/// on the sync sample at or before `t` and trims the frame straddling it (see
/// [`SEEK_BACKOFF_100NS`]), so callers finish the job by discarding frames
/// before the target.
unsafe fn build_reader(
asset: ObjcId,
video_track: ObjcId,
audio_track: Option<ObjcId>,
start_100ns: Option<i64>,
) -> Result<ReaderSet, VideoFileError> {
let mut error: ObjcId = nil;
let reader: ObjcId =
msg_send![class!(AVAssetReader), assetReaderWithAsset: asset error: &mut error];
if reader == nil {
return Err(nserror_to_video_error("AVAssetReader init", error));
}
if let Some(start) = start_100ns {
let range = CMTimeRange {
start: CMTime {
value: start,
timescale: HNS_TIMESCALE,
flags: kCMTimeFlags_Valid,
epoch: 0,
},
duration: kCMTimePositiveInfinity,
};
let _: () = msg_send![reader, setTimeRange: range];
}
let nv12_number: ObjcId = msg_send![
class!(NSNumber),
numberWithUnsignedInt: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange
];
let keys: &[ObjcId] = &[kCVPixelBufferPixelFormatTypeKey as ObjcId];
let values: &[ObjcId] = &[nv12_number];
let video_settings: ObjcId = msg_send![
class!(NSDictionary),
dictionaryWithObjects: values.as_ptr()
forKeys: keys.as_ptr()
count: 1usize
];
let video_output: ObjcId = msg_send![class!(AVAssetReaderTrackOutput), alloc];
let video_output: ObjcId = msg_send![
video_output,
initWithTrack: video_track
outputSettings: video_settings
];
if video_output == nil {
return Err(VideoFileError::new(
"AVAssetReaderTrackOutput(video NV12) init failed",
));
}
// We repack into our own buffer before releasing the sample, so
// the vended buffer may be recycled.
let _: () = msg_send![video_output, setAlwaysCopiesSampleData: NO];
let can_add: BOOL = msg_send![reader, canAddOutput: video_output];
if can_add == NO {
let _: () = msg_send![video_output, release];
return Err(VideoFileError::new("AVAssetReader rejected video output"));
}
let _: () = msg_send![reader, addOutput: video_output];
// Optional audio track -> 16-bit interleaved PCM at native rate/layout.
let mut audio_output_obj: ObjcId = nil;
if let Some(audio_track) = audio_track {
let format_number: ObjcId = msg_send![
class!(NSNumber),
numberWithUnsignedInt: AudioFormatId::LinearPCM as u32
];
let bits_number: ObjcId = msg_send![class!(NSNumber), numberWithInt: 16i32];
let no_number: ObjcId = msg_send![class!(NSNumber), numberWithBool: NO];
let keys: &[ObjcId] = &[
AVFormatIDKey,
AVLinearPCMBitDepthKey,
AVLinearPCMIsFloatKey,
AVLinearPCMIsBigEndianKey,
AVLinearPCMIsNonInterleaved,
];
let values: &[ObjcId] = &[format_number, bits_number, no_number, no_number, no_number];
let audio_settings: ObjcId = msg_send![
class!(NSDictionary),
dictionaryWithObjects: values.as_ptr()
forKeys: keys.as_ptr()
count: keys.len()
];
let audio_output: ObjcId = msg_send![class!(AVAssetReaderTrackOutput), alloc];
let audio_output: ObjcId = msg_send![
audio_output,
initWithTrack: audio_track
outputSettings: audio_settings
];
if audio_output != nil {
let _: () = msg_send![audio_output, setAlwaysCopiesSampleData: NO];
let can_add: BOOL = msg_send![reader, canAddOutput: audio_output];
if can_add == YES {
let _: () = msg_send![reader, addOutput: audio_output];
audio_output_obj = audio_output;
} else {
let _: () = msg_send![audio_output, release];
}
}
}
let started: BOOL = msg_send![reader, startReading];
if started == NO {
let error: ObjcId = msg_send![reader, error];
if audio_output_obj != nil {
let _: () = msg_send![audio_output_obj, release];
}
let _: () = msg_send![video_output, release];
return Err(nserror_to_video_error("AVAssetReader startReading", error));
}
Ok(ReaderSet {
reader: RcObjcId::from_unowned(NonNull::new(reader).unwrap()),
// initWithTrack: returned +1; from_owned takes that reference.
video_output: RcObjcId::from_owned(NonNull::new(video_output).unwrap()),
audio_output: NonNull::new(audio_output_obj).map(RcObjcId::from_owned),
})
}
// The decoder is pulled from one thread at a time; the ObjC objects it wraps
// are not thread-affine (AVAssetReader is documented safe for serial use off
// the main thread).
unsafe impl Send for MacosVideoFileDecoder {}
impl MacosVideoFileDecoder {
pub fn open(path: &str) -> Result<Self, VideoFileError> {
if !std::path::Path::new(path).is_file() {
return Err(VideoFileError::new(format!("file not found: {}", path)));
}
let _pool = AutoreleasePool::new();
unsafe {
let ns_path = str_to_nsstring(path);
let url: ObjcId = msg_send![class!(NSURL), fileURLWithPath: ns_path];
let asset: ObjcId = msg_send![class!(AVURLAsset), URLAssetWithURL: url options: nil];
if asset == nil {
return Err(VideoFileError::new(format!(
"AVURLAsset failed to open {}",
path
)));
}
// --- Video track (required, mirrors "no video stream in file"). ---
let video_tracks: ObjcId = msg_send![asset, tracksWithMediaType: AVMediaTypeVideo];
let video_track_count: usize = msg_send![video_tracks, count];
if video_track_count == 0 {
return Err(VideoFileError::new("no video stream in file"));
}
let video_track: ObjcId = msg_send![video_tracks, objectAtIndex: 0usize];
// Compressed codec + coded dimensions from the format description.
let mut video_codec = None;
let mut video_codec_fourcc = 0u32;
let mut coded_width = 0u32;
let mut coded_height = 0u32;
let format_descs: ObjcId = msg_send![video_track, formatDescriptions];
let format_desc_count: usize = msg_send![format_descs, count];
if format_desc_count > 0 {
let desc: CMFormatDescriptionRef =
msg_send![format_descs, objectAtIndex: 0usize];
video_codec_fourcc = CMFormatDescriptionGetMediaSubType(desc);
video_codec = match video_codec_fourcc {
f if f == four_char_as_u32("hvc1") || f == four_char_as_u32("hev1") => {
Some(VideoFileCodec::H265)
}
f if f == four_char_as_u32("avc1") || f == four_char_as_u32("avc3") => {
Some(VideoFileCodec::H264)
}
_ => None,
};
let dims = CMVideoFormatDescriptionGetDimensions(desc);
coded_width = dims.width.max(0) as u32;
coded_height = dims.height.max(0) as u32;
}
// Display picture size: the container's natural size is the
// display aperture (the coded surface may be block/CTU padded).
let natural: NSSize = msg_send![video_track, naturalSize];
let mut width = natural.width.round().max(0.0) as u32;
let mut height = natural.height.round().max(0.0) as u32;
if width == 0 || height == 0 {
width = coded_width;
height = coded_height;
} else if coded_width != 0 && coded_height != 0 {
width = width.min(coded_width.max(width));
height = height.min(coded_height.max(height));
}
if width == 0 || height == 0 {
return Err(VideoFileError::new("video track reports zero size"));
}
// Frame rate: minFrameDuration is an exact rational for CFR
// content; fall back to nominalFrameRate, then 30/1.
let min_dur: CMTime = msg_send![video_track, minFrameDuration];
let (mut fps_num, mut fps_den) =
if min_dur.flags & kCMTimeFlags_Valid != 0 && min_dur.value > 0 && min_dur.timescale > 0 {
(min_dur.timescale as u32, min_dur.value as u32)
} else {
let nominal: f32 = msg_send![video_track, nominalFrameRate];
if nominal > 0.0 {
((nominal * 1000.0).round() as u32, 1000)
} else {
(30, 1)
}
};
let g = gcd(fps_num, fps_den);
fps_num /= g;
fps_den /= g;
let duration: CMTime = msg_send![asset, duration];
let duration_100ns = cmtime_to_100ns(duration);
// --- Optional audio track: native rate/layout from its stream
// description; the output itself is built with the reader. ---
let mut audio_sample_rate = 0u32;
let mut audio_channels = 0u16;
let mut audio_track_obj: ObjcId = nil;
let audio_tracks: ObjcId = msg_send![asset, tracksWithMediaType: AVMediaTypeAudio];
let audio_track_count: usize = msg_send![audio_tracks, count];
if audio_track_count > 0 {
audio_track_obj = msg_send![audio_tracks, objectAtIndex: 0usize];
let mut rate = 48000u32;
let mut channels = 2u16;
let audio_descs: ObjcId = msg_send![audio_track_obj, formatDescriptions];
let audio_desc_count: usize = msg_send![audio_descs, count];
if audio_desc_count > 0 {
let desc: CMFormatDescriptionRef =
msg_send![audio_descs, objectAtIndex: 0usize];
let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(desc);
if !asbd.is_null() {
let asbd = &*asbd;
if asbd.mSampleRate > 0.0 {
rate = asbd.mSampleRate as u32;
}
if asbd.mChannelsPerFrame > 0 {
channels = asbd.mChannelsPerFrame.min(u16::MAX as u32) as u16;
}
}
}
audio_sample_rate = rate;
audio_channels = channels;
}
let set = build_reader(
asset,
video_track,
(audio_track_obj != nil).then_some(audio_track_obj),
None,
)?;
// The track only counts as audio if the reader took its output;
// dropping it here also stops `seek` from re-attempting it.
let has_audio = set.audio_output.is_some();
if !has_audio {
audio_track_obj = nil;
audio_sample_rate = 0;
audio_channels = 0;
}
Ok(Self {
asset: RcObjcId::from_unowned(NonNull::new(asset).unwrap()),
video_track: RcObjcId::from_unowned(NonNull::new(video_track).unwrap()),
audio_track: NonNull::new(audio_track_obj).map(RcObjcId::from_unowned),
reader: set.reader,
video_output: set.video_output,
audio_output: set.audio_output,
info: VideoFileInfo {
width,
height,
fps_num,
fps_den,
duration_100ns,
video_codec,
video_codec_fourcc,
has_audio,
audio_sample_rate,
audio_channels,
},
video_eos: false,
audio_eos: false,
})
}
}
pub fn info(&self) -> &VideoFileInfo {
&self.info
}
/// Pull one sample buffer from an output; maps the null return to either
/// EOS (`Ok(None)`) or a reader failure.
unsafe fn copy_next_sample(
&self,
output: ObjcId,
context: &str,
) -> Result<Option<CMSampleBufferRef>, VideoFileError> {
let sample: CMSampleBufferRef = msg_send![output, copyNextSampleBuffer];
if !sample.is_null() {
return Ok(Some(sample));
}
let reader = self.reader.as_id();
let status: i64 = msg_send![reader, status];
if status == READER_STATUS_FAILED {
let error: ObjcId = msg_send![reader, error];
return Err(nserror_to_video_error(context, error));
}
Ok(None)
}
pub fn next_frame(&mut self) -> Result<Option<DecodedVideoFrame>, VideoFileError> {
if self.video_eos {
return Ok(None);
}
let _pool = AutoreleasePool::new();
unsafe {
let Some(sample) =
self.copy_next_sample(self.video_output.as_id(), "copyNextSampleBuffer(video)")?
else {
self.video_eos = true;
return Ok(None);
};
let pts_100ns = cmtime_to_100ns(CMSampleBufferGetPresentationTimeStamp(sample));
let image = CMSampleBufferGetImageBuffer(sample);
if image.is_null() {
CFRelease(sample as *const c_void);
return Err(VideoFileError::new("video sample has no image buffer"));
}
// Repack the (possibly row-padded) biplanar surface into a tight
// display-sized NV12 buffer. CoreVideo reports display dimensions
// on the buffer; codec padding sits beyond bytes-per-row.
const K_CV_PIXEL_BUFFER_LOCK_READ_ONLY: CVPixelBufferLockFlags = 1;
CVPixelBufferLockBaseAddress(image, K_CV_PIXEL_BUFFER_LOCK_READ_ONLY);
let buf_width = CVPixelBufferGetWidth(image);
let buf_height = CVPixelBufferGetHeight(image);
// Track the decoded surface if it disagrees with the container
// (mirrors the Windows media-type-changed refresh: never report
// more pixels than the decoder produced).
self.info.width = self.info.width.min(buf_width.max(1) as u32).max(1);
self.info.height = self.info.height.min(buf_height.max(1) as u32).max(1);
let width = self.info.width as usize;
let height = self.info.height as usize;
let uv_height = height.div_ceil(2);
let mut nv12 = vec![0u8; width * (height + uv_height)];
if CVPixelBufferIsPlanar(image) && CVPixelBufferGetPlaneCount(image) >= 2 {
let y_ptr = CVPixelBufferGetBaseAddressOfPlane(image, 0) as *const u8;
let uv_ptr = CVPixelBufferGetBaseAddressOfPlane(image, 1) as *const u8;
let y_stride = CVPixelBufferGetBytesPerRowOfPlane(image, 0);
let uv_stride = CVPixelBufferGetBytesPerRowOfPlane(image, 1);
let src_uv_height = CVPixelBufferGetHeightOfPlane(image, 1).min(uv_height);
if !y_ptr.is_null() && !uv_ptr.is_null() {
for row in 0..height {
let src = std::slice::from_raw_parts(y_ptr.add(row * y_stride), width);
nv12[row * width..row * width + width].copy_from_slice(src);
}
for row in 0..src_uv_height {
let src = std::slice::from_raw_parts(uv_ptr.add(row * uv_stride), width);
let dst = (height + row) * width;
nv12[dst..dst + width].copy_from_slice(src);
}
}
}
CVPixelBufferUnlockBaseAddress(image, K_CV_PIXEL_BUFFER_LOCK_READ_ONLY);
CFRelease(sample as *const c_void);
Ok(Some(DecodedVideoFrame {
pts_100ns,
width: self.info.width,
height: self.info.height,
nv12,
}))
}
}
pub fn next_audio(&mut self) -> Result<Option<DecodedAudioChunk>, VideoFileError> {
if self.audio_eos || !self.info.has_audio {
return Ok(None);
}
let Some(audio_output) = &self.audio_output else {
return Ok(None);
};
let audio_output = audio_output.as_id();
let _pool = AutoreleasePool::new();
unsafe {
let Some(sample) =
self.copy_next_sample(audio_output, "copyNextSampleBuffer(audio)")?
else {
self.audio_eos = true;
return Ok(None);
};
let pts_100ns = cmtime_to_100ns(CMSampleBufferGetPresentationTimeStamp(sample));
let block = CMSampleBufferGetDataBuffer(sample);
if block.is_null() {
CFRelease(sample as *const c_void);
return Err(VideoFileError::new("audio sample has no data buffer"));
}
let len = CMBlockBufferGetDataLength(block).max(0) as usize;
let sample_count = len / 2;
let mut samples = vec![0i16; sample_count];
let status = CMBlockBufferCopyDataBytes(
block,
0,
(sample_count * 2) as i32,
samples.as_mut_ptr() as *mut c_void,
);
CFRelease(sample as *const c_void);
if status != 0 {
return Err(VideoFileError::with_code(
"CMBlockBufferCopyDataBytes(audio)",
status,
));
}
Ok(Some(DecodedAudioChunk {
pts_100ns,
sample_rate: self.info.audio_sample_rate,
channels: self.info.audio_channels,
samples,
}))
}
}
/// Position the demuxer at or before `target_100ns` and re-arm both
/// streams. Landing exactly on the target is the facade's job.
///
/// AVAssetReader is a one-shot forward pipeline with no seek, so this
/// replaces it: a fresh reader over the same asset whose `timeRange`
/// starts at the target. AVFoundation begins at the sync sample at or
/// before it, and the caller decodes forward from there.
///
/// Standing that reader up is what a seek costs here — MEASURED on a
/// 320x192 clip, a seek plus one frame is 8.7 ms on an all-intra file and
/// 11.2 ms on a GOP file, so the ~2.5 ms of GOP walking is small beside
/// the rebuild both pay. AVFoundation's cheaper-looking alternative,
/// `supportsRandomAccess` + `resetForReadingTimeRanges:`, was tried and
/// does not survive contact: it raises an Objective-C exception here even
/// with random access enabled before `startReading` and a finite range,
/// and an ObjC exception unwinding into Rust aborts the process. A faster
/// seek is not worth a crash the type system cannot see.
pub fn seek_to(&mut self, target_100ns: i64) -> Result<(), VideoFileError> {
let mut start = (target_100ns - SEEK_BACKOFF_100NS).max(0);
if self.info.duration_100ns > 0 {
// A range beginning at or past the end is not a range
// AVAssetReader accepts — `startReading` fails outright. Clamp
// inside the asset so a past-the-end target reaches EOS instead.
start = start.min(self.info.duration_100ns - 1);
}
let _pool = AutoreleasePool::new();
unsafe {
let set = build_reader(
self.asset.as_id(),
self.video_track.as_id(),
self.audio_track.as_ref().map(|t| t.as_id()),
Some(start),
)?;
// Only now that the replacement is reading: a failed build leaves
// the decoder exactly where it was.
let old_reader = self.reader.as_id();
let _: () = msg_send![old_reader, cancelReading];
self.reader = set.reader;
self.video_output = set.video_output;
self.audio_output = set.audio_output;
}
self.video_eos = false;
self.audio_eos = false;
Ok(())
}
}
impl Drop for MacosVideoFileDecoder {
fn drop(&mut self) {
unsafe {
let reader = self.reader.as_id();
let _: () = msg_send![reader, cancelReading];
}
}
}