avplayer 0.7.0

Safe Rust bindings for Apple's AVPlayer + AVAssetReader — playback and frame-by-frame asset reading on macOS
Documentation
import AVFoundation
import CoreMedia
import CoreVideo
import Foundation

struct PlayerVideoOutputTagCollectionPayload: Codable {
    let tags: [String]
}

struct AffineTransformPayload: Codable {
    let a: Double
    let b: Double
    let c: Double
    let d: Double
    let tx: Double
    let ty: Double
}

struct PlayerVideoOutputConfigurationPayload: Codable {
    let hasSourcePlayerItem: Bool
    let dataChannelDescriptions: [[String]]
    let preferredTransform: AffineTransformPayload?
    let activationTime: TimePayload
}

struct PlayerVideoTaggedBufferPayload: Codable {
    let tags: [String]
    let bufferKind: String
    let pixelBufferWidth: Int?
    let pixelBufferHeight: Int?
}

struct PlayerVideoOutputSamplePayload: Codable {
    let taggedBuffers: [PlayerVideoTaggedBufferPayload]
    let presentationTime: TimePayload
    let activeConfiguration: PlayerVideoOutputConfigurationPayload
}

@available(macOS 14.0, *)
final class AVPPlayerVideoOutputTagCollectionBox: NSObject {
    let tags: [CMTag]

    init(tags: [CMTag]) {
        self.tags = tags
    }
}

@available(macOS 14.2, *)
final class AVPVideoOutputSpecificationBox: NSObject {
    let specification: AVVideoOutputSpecification

    init(specification: AVVideoOutputSpecification) {
        self.specification = specification
    }
}

@available(macOS 14.2, *)
final class AVPPlayerVideoOutputBox: NSObject {
    let videoOutput: AVPlayerVideoOutput

    init(videoOutput: AVPlayerVideoOutput) {
        self.videoOutput = videoOutput
    }
}

@_cdecl("av_player_video_output_tag_collection_create_with_preset")
public func av_player_video_output_tag_collection_create_with_preset(
    _ presetRaw: UInt32,
    _ outErrorMessage: UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>?
) -> UnsafeMutableRawPointer? {
    guard #available(macOS 14.2, *) else {
        outErrorMessage?.pointee = ffiString("AVPlayerVideoOutput tag collections require macOS 14.2+")
        return nil
    }

    let tags: [CMTag]
    switch presetRaw {
    case 0:
        tags = [CMTag.stereoView([])]
    case 1:
        tags = [CMTag.stereoView([.leftEye, .rightEye])]
    default:
        outErrorMessage?.pointee = ffiString("invalid CMTagCollectionVideoOutputPreset raw value: \(presetRaw)")
        return nil
    }

    return Unmanaged.passRetained(AVPPlayerVideoOutputTagCollectionBox(tags: tags)).toOpaque()
}

@_cdecl("av_player_video_output_tag_collection_release")
public func av_player_video_output_tag_collection_release(_ tagCollectionPtr: UnsafeMutableRawPointer?) {
    guard let tagCollectionPtr else { return }
    if #available(macOS 14.0, *) {
        Unmanaged<AVPPlayerVideoOutputTagCollectionBox>.fromOpaque(tagCollectionPtr).release()
    }
}

@_cdecl("av_player_video_output_tag_collection_info_json")
public func av_player_video_output_tag_collection_info_json(
    _ tagCollectionPtr: UnsafeMutableRawPointer,
    _ outErrorMessage: UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>?
) -> UnsafeMutablePointer<CChar>? {
    guard #available(macOS 14.0, *) else {
        outErrorMessage?.pointee = ffiString("AVPlayerVideoOutput tag collections require macOS 14.0+")
        return nil
    }

    let box = Unmanaged<AVPPlayerVideoOutputTagCollectionBox>.fromOpaque(tagCollectionPtr).takeUnretainedValue()
    let payload = PlayerVideoOutputTagCollectionPayload(tags: box.tags.map(\.description))
    do {
        return ffiString(try avpEncodeJSON(payload))
    } catch {
        outErrorMessage?.pointee = ffiString(error.localizedDescription)
        return nil
    }
}

@_cdecl("av_video_output_specification_create")
public func av_video_output_specification_create(
    _ tagCollectionPtrs: UnsafePointer<UnsafeMutableRawPointer?>?,
    _ count: Int,
    _ outErrorMessage: UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>?
) -> UnsafeMutableRawPointer? {
    guard #available(macOS 14.2, *) else {
        outErrorMessage?.pointee = ffiString("AVVideoOutputSpecification requires macOS 14.2+")
        return nil
    }
    guard let tagCollectionPtrs, count > 0 else {
        outErrorMessage?.pointee = ffiString("video output specifications require at least one tag collection")
        return nil
    }

    let tagCollections = (0..<count).compactMap { index -> [CMTag]? in
        guard let ptr = tagCollectionPtrs[index] else { return nil }
        let box = Unmanaged<AVPPlayerVideoOutputTagCollectionBox>.fromOpaque(ptr).takeUnretainedValue()
        return box.tags
    }
    guard tagCollections.count == count else {
        outErrorMessage?.pointee = ffiString("video output specification tag collection list contained nil")
        return nil
    }

    let specification = AVVideoOutputSpecification(tagCollections: tagCollections)
    return Unmanaged.passRetained(AVPVideoOutputSpecificationBox(specification: specification)).toOpaque()
}

@_cdecl("av_video_output_specification_release")
public func av_video_output_specification_release(_ specificationPtr: UnsafeMutableRawPointer?) {
    guard let specificationPtr else { return }
    if #available(macOS 14.2, *) {
        Unmanaged<AVPVideoOutputSpecificationBox>.fromOpaque(specificationPtr).release()
    }
}

@_cdecl("av_video_output_specification_info_json")
public func av_video_output_specification_info_json(
    _ specificationPtr: UnsafeMutableRawPointer,
    _ outErrorMessage: UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>?
) -> UnsafeMutablePointer<CChar>? {
    guard #available(macOS 14.2, *) else {
        outErrorMessage?.pointee = ffiString("AVVideoOutputSpecification requires macOS 14.2+")
        return nil
    }

    let box = Unmanaged<AVPVideoOutputSpecificationBox>.fromOpaque(specificationPtr).takeUnretainedValue()
    let payload = box.specification.preferredTagCollections.map {
        PlayerVideoOutputTagCollectionPayload(tags: $0.map(\.description))
    }
    do {
        return ffiString(try avpEncodeJSON(payload))
    } catch {
        outErrorMessage?.pointee = ffiString(error.localizedDescription)
        return nil
    }
}

@_cdecl("av_video_output_specification_set_default_output_settings")
public func av_video_output_specification_set_default_output_settings(
    _ specificationPtr: UnsafeMutableRawPointer,
    _ settingsJson: UnsafePointer<CChar>?,
    _ outErrorMessage: UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>?
) -> Int32 {
    guard #available(macOS 14.2, *) else {
        outErrorMessage?.pointee = ffiString("AVVideoOutputSpecification requires macOS 14.2+")
        return AVP_OPERATION_FAILED
    }
    guard #available(macOS 15.0, *) else {
        outErrorMessage?.pointee = ffiString("defaultOutputSettings requires macOS 15.0+")
        return AVP_OPERATION_FAILED
    }

    let box = Unmanaged<AVPVideoOutputSpecificationBox>.fromOpaque(specificationPtr).takeUnretainedValue()
    do {
        let settings = try settingsJson.map {
            try videoOutputSettingsDictionary(avpDecodeJSON($0, as: VideoOutputSettingsPayload.self))
        }
        box.specification.defaultOutputSettings = settings
        return AVP_OK
    } catch {
        outErrorMessage?.pointee = ffiString(error.localizedDescription)
        return AVP_OPERATION_FAILED
    }
}

@_cdecl("av_video_output_specification_set_output_settings_for_tag_collection")
public func av_video_output_specification_set_output_settings_for_tag_collection(
    _ specificationPtr: UnsafeMutableRawPointer,
    _ settingsJson: UnsafePointer<CChar>?,
    _ tagCollectionPtr: UnsafeMutableRawPointer,
    _ outErrorMessage: UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>?
) -> Int32 {
    guard #available(macOS 14.2, *) else {
        outErrorMessage?.pointee = ffiString("AVVideoOutputSpecification requires macOS 14.2+")
        return AVP_OPERATION_FAILED
    }
    guard #available(macOS 15.0, *) else {
        outErrorMessage?.pointee = ffiString("setOutputSettings(_:for:) requires macOS 15.0+")
        return AVP_OPERATION_FAILED
    }

    let box = Unmanaged<AVPVideoOutputSpecificationBox>.fromOpaque(specificationPtr).takeUnretainedValue()
    let tagCollectionBox = Unmanaged<AVPPlayerVideoOutputTagCollectionBox>.fromOpaque(tagCollectionPtr).takeUnretainedValue()
    do {
        let settings = try settingsJson.map {
            try videoOutputSettingsDictionary(avpDecodeJSON($0, as: VideoOutputSettingsPayload.self))
        }
        box.specification.setOutputSettings(settings, for: tagCollectionBox.tags)
        return AVP_OK
    } catch {
        outErrorMessage?.pointee = ffiString(error.localizedDescription)
        return AVP_OPERATION_FAILED
    }
}

@_cdecl("av_player_video_output_create")
public func av_player_video_output_create(
    _ specificationPtr: UnsafeMutableRawPointer,
    _ outErrorMessage: UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>?
) -> UnsafeMutableRawPointer? {
    guard #available(macOS 14.2, *) else {
        outErrorMessage?.pointee = ffiString("AVPlayerVideoOutput requires macOS 14.2+")
        return nil
    }

    let specification = Unmanaged<AVPVideoOutputSpecificationBox>.fromOpaque(specificationPtr).takeUnretainedValue().specification
    let output = AVPlayerVideoOutput(specification: specification)
    return Unmanaged.passRetained(AVPPlayerVideoOutputBox(videoOutput: output)).toOpaque()
}

@_cdecl("av_player_video_output_release")
public func av_player_video_output_release(_ outputPtr: UnsafeMutableRawPointer?) {
    guard let outputPtr else { return }
    if #available(macOS 14.2, *) {
        Unmanaged<AVPPlayerVideoOutputBox>.fromOpaque(outputPtr).release()
    }
}

@_cdecl("av_player_video_output_sample_json")
public func av_player_video_output_sample_json(
    _ outputPtr: UnsafeMutableRawPointer,
    _ hostTimeValue: Int64,
    _ hostTimeTimescale: Int32,
    _ hostTimeKind: Int32,
    _ outErrorMessage: UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>?
) -> UnsafeMutablePointer<CChar>? {
    guard #available(macOS 14.2, *) else {
        outErrorMessage?.pointee = ffiString("AVPlayerVideoOutput requires macOS 14.2+")
        return nil
    }

    let output = Unmanaged<AVPPlayerVideoOutputBox>.fromOpaque(outputPtr).takeUnretainedValue().videoOutput
    let hostTime = cmTime(value: hostTimeValue, timescale: hostTimeTimescale, kind: hostTimeKind)
    let sample = output.taggedBuffers(forHostTime: hostTime).map { sample in
        PlayerVideoOutputSamplePayload(
            taggedBuffers: sample.taggedBufferGroup.map(encodeTaggedBuffer),
            presentationTime: encodeTime(sample.presentationTime),
            activeConfiguration: encodePlayerVideoOutputConfiguration(sample.activeConfiguration)
        )
    }
    do {
        return ffiString(try avpEncodeJSON(sample))
    } catch {
        outErrorMessage?.pointee = ffiString(error.localizedDescription)
        return nil
    }
}

@_cdecl("av_player_set_video_output")
public func av_player_set_video_output(
    _ playerPtr: UnsafeMutableRawPointer,
    _ outputPtr: UnsafeMutableRawPointer?
) {
    guard #available(macOS 14.2, *) else { return }
    let player = Unmanaged<AVPlayer>.fromOpaque(playerPtr).takeUnretainedValue()
    let output = outputPtr.map { Unmanaged<AVPPlayerVideoOutputBox>.fromOpaque($0).takeUnretainedValue().videoOutput }
    player.videoOutput = output
}

@_cdecl("av_player_copy_video_output")
public func av_player_copy_video_output(_ playerPtr: UnsafeMutableRawPointer) -> UnsafeMutableRawPointer? {
    guard #available(macOS 14.2, *) else { return nil }
    let player = Unmanaged<AVPlayer>.fromOpaque(playerPtr).takeUnretainedValue()
    guard let output = player.videoOutput else { return nil }
    return Unmanaged.passRetained(AVPPlayerVideoOutputBox(videoOutput: output)).toOpaque()
}

@available(macOS 14.2, *)
private func encodePlayerVideoOutputConfiguration(
    _ configuration: AVPlayerVideoOutput.Configuration
) -> PlayerVideoOutputConfigurationPayload {
    PlayerVideoOutputConfigurationPayload(
        hasSourcePlayerItem: configuration.sourcePlayerItem != nil,
        dataChannelDescriptions: configuration.dataChannelDescription.map { $0.map(\.description) },
        preferredTransform: {
            if #available(macOS 15.0, *) {
                return encodeAffineTransform(configuration.preferredTransform)
            }
            return nil
        }(),
        activationTime: encodeTime(configuration.activationTime)
    )
}

private func encodeAffineTransform(_ transform: CGAffineTransform) -> AffineTransformPayload {
    AffineTransformPayload(
        a: transform.a,
        b: transform.b,
        c: transform.c,
        d: transform.d,
        tx: transform.tx,
        ty: transform.ty
    )
}

@available(macOS 14.0, *)
private func encodeTaggedBuffer(_ taggedBuffer: CMTaggedBuffer) -> PlayerVideoTaggedBufferPayload {
    let pixelBufferSize: (Int?, Int?)
    let bufferKind: String
    switch taggedBuffer.buffer {
    case .pixelBuffer(let pixelBuffer):
        bufferKind = "pixel_buffer"
        pixelBufferSize = (CVPixelBufferGetWidth(pixelBuffer), CVPixelBufferGetHeight(pixelBuffer))
    case .sampleBuffer:
        bufferKind = "sample_buffer"
        pixelBufferSize = (nil, nil)
    @unknown default:
        bufferKind = "unknown"
        pixelBufferSize = (nil, nil)
    }
    return PlayerVideoTaggedBufferPayload(
        tags: taggedBuffer.tags.map(\.description),
        bufferKind: bufferKind,
        pixelBufferWidth: pixelBufferSize.0,
        pixelBufferHeight: pixelBufferSize.1
    )
}

private func videoOutputSettingsDictionary(_ payload: VideoOutputSettingsPayload) throws -> [String: Any] {
    var settings: [String: Any] = [
        kCVPixelBufferPixelFormatTypeKey as String: Int(payload.pixelFormat)
    ]
    if let width = payload.width {
        settings[kCVPixelBufferWidthKey as String] = width
    }
    if let height = payload.height {
        settings[kCVPixelBufferHeightKey as String] = height
    }
    return settings
}