Skip to content

Instantly share code, notes, and snippets.

@samsonjs
Created July 8, 2024 15:54
Show Gist options
  • Save samsonjs/e25b6ad7939e4e6b11a2cdb6943fae2f to your computer and use it in GitHub Desktop.
Save samsonjs/e25b6ad7939e4e6b11a2cdb6943fae2f to your computer and use it in GitHub Desktop.
Attempting to send non-Sendable AVFoundation types in a safe way
public import AVFoundation
struct UniqueRef<Value>: ~Copyable, @unchecked Sendable {
private let lock = NSLock()
private var unsafeValue: Value?
init(value: sending Value) {
self.unsafeValue = value
}
mutating func take() -> sending Value? {
lock.withLock {
defer { unsafeValue = nil }
return unsafeValue
}
}
}
public struct Settings: ~Copyable, Sendable {
private var uniqueMix: UniqueRef<AVAudioMix>?
public init(mix: sending AVAudioMix?) {
self.uniqueMix = mix.map(UniqueRef.init)
}
mutating func takeMix() -> sending AVAudioMix? {
uniqueMix?.take()
}
}
public final class ExampleExportSession: Sendable {
func export(settings: consuming sending Settings) async throws {
let writer = ExampleWriter(mix: settings.takeMix())
try await writer.write()
}
}
actor ExampleWriter {
let mix: AVAudioMix?
init(mix: sending AVAudioMix?) {
self.mix = mix
}
func write() async throws {}
}
@mattmassicotte
Copy link

Why doesn't this work:

public final class ExampleExportSession: Sendable {
	func export(settings: sending AVAudioMix) async throws {
		let writer = ExampleWriter(mix: settings)
		try await writer.write()
	}
}
actor ExampleWriter {
	let mix: AVAudioMix?
	init(mix: sending AVAudioMix?) {
		self.mix = mix
	}
	func write() async throws {}
}

@samsonjs
Copy link
Author

samsonjs commented Jul 8, 2024

@mattmassicotte That does work but I really do want to pass it in settings to keep the number of parameters down. The real class has a bunch of inputs and I want to make a nicer API for calling it. Here's a bit more of the real code to illustrate what's going on, and you can ignore a lot of AudioOutputSettings.

I was passing the audio mix around separately instead of shoving it into AudioOutputSettings, but it's just a lot of params on the export method. I marked the example with a ⭐️

public struct AudioOutputSettings: Sendable {
    public enum Format {
        case aac
        case mp3

        var formatID: AudioFormatID {
            switch self {
            case .aac: kAudioFormatMPEG4AAC
            case .mp3: kAudioFormatMPEGLayer3
            }
        }
    }

    var format: AudioFormatID
    var channels: Int
    var sampleRate: Int?

    private var uniqueMix: UniqueRef<AVAudioMix>?
    var mix: AVAudioMix? {
        mutating get {
            uniqueMix?.take()
        }
    }

    public static func format(_ format: Format) -> AudioOutputSettings {
        .init(format: format.formatID, channels: 2, sampleRate: nil, uniqueMix: nil)
    }

    public consuming func channels(_ channels: Int) -> AudioOutputSettings {
        self.channels = channels
        return self
    }

    public consuming func sampleRate(_ sampleRate: Int?) -> AudioOutputSettings {
        self.sampleRate = sampleRate
        return self
    }

    public consuming func mix(_ mix: sending AVAudioMix?) -> AudioOutputSettings {
        self.uniqueMix = mix.map(UniqueRef.init)
        return self
    }

    var settingsDictionary: [String: any Sendable] {
        if let sampleRate {
            [
                AVFormatIDKey: format,
                AVNumberOfChannelsKey: NSNumber(value: channels),
                AVSampleRateKey: NSNumber(value: Float(sampleRate)),
            ]
        } else {
            [
                AVFormatIDKey: format,
                AVNumberOfChannelsKey: NSNumber(value: channels),
            ]
        }
    }
}
public final class ExportSession: @unchecked Sendable {
    // @unchecked Sendable because progress properties are mutable, it's safe though.

    public typealias ProgressStream = AsyncStream<Float>

    public var progressStream: ProgressStream = ProgressStream(unfolding: { 0.0 })

    private var progressContinuation: ProgressStream.Continuation?

    public init() {
        progressStream = AsyncStream { continuation in
            progressContinuation = continuation
        }
    }
    
    static func exampleExport(
        asset: sending AVAsset,
        audioMix: sending AVAudioMix?,
        videoComposition: sending AVVideoComposition?
    ) async throws {
        let session = ExportSession()
        // ⭐️ this is the API I'd like to support, and the mix could be passed separately like the video composition, but I think this looks a bit cleaner and simpler. I want to do the same with the video composition as well
        try await session.export(
            asset: asset,
            timeRange: CMTimeRange(start: .seconds(1), duration: .seconds(2)),
            audio: .format(.aac).channels(1).sampleRate(22_050).mix(audioMix),
            video: .codec(.h264(profile: .baselineAuto))
                .dimensions(width: 1280, height: 720)
                .bitrate(2_000_000)
                .color(.sdr),
            composition: videoComposition,
            optimizeForNetworkUse: true,
            to: URL(fileURLWithPath: "/"),
            as: .mp4
        )
    }

    public func export(
        asset: sending AVAsset,
        timeRange: CMTimeRange? = nil,
        audio: sending AudioOutputSettings,
        video: VideoOutputSettings,
        composition: sending AVVideoComposition? = nil,
        optimizeForNetworkUse: Bool = false,
        to outputURL: URL,
        as fileType: AVFileType
    ) async throws {
        let videoComposition: AVVideoComposition
        if let composition {
            videoComposition = composition
        } else {
            let composition = try await AVMutableVideoComposition.videoComposition(
                withPropertiesOf: asset
            )
            if let dimensions = video.dimensions { composition.renderSize = dimensions }
            videoComposition = composition
        }
        try await export(
            asset: asset,
            audioOutputSettings: audio.settingsDictionary,
            audioMix: audio.mix,
            videoOutputSettings: video.settingsDictionary(renderSize: videoComposition.renderSize),
            composition: videoComposition,
            to: outputURL,
            as: fileType
        )
    }

    public func export(
        asset: sending AVAsset,
        timeRange: CMTimeRange? = nil,
        audioOutputSettings: [String: (any Sendable)],
        audioMix: sending AVAudioMix? = nil,
        videoOutputSettings: [String: (any Sendable)],
        composition: sending AVVideoComposition? = nil,
        optimizeForNetworkUse: Bool = false,
        to outputURL: URL,
        as fileType: AVFileType
    ) async throws {
        let videoComposition: AVVideoComposition
        if let composition {
            videoComposition = composition
        } else {
            let composition = try await AVMutableVideoComposition.videoComposition(
                withPropertiesOf: asset
            )
            videoComposition = composition
        }
        let sampleWriter = try await SampleWriter(
            asset: asset,
            audioMix: audioMix,
            audioOutputSettings: audioOutputSettings,
            videoComposition: videoComposition,
            videoOutputSettings: videoOutputSettings,
            timeRange: timeRange,
            optimizeForNetworkUse: optimizeForNetworkUse,
            outputURL: outputURL,
            fileType: fileType
        )
        Task { [progressContinuation] in
            for await progress in await sampleWriter.progressStream {
                progressContinuation?.yield(progress)
            }
        }
        try await sampleWriter.writeSamples()
    }
}

@samsonjs
Copy link
Author

samsonjs commented Jul 9, 2024

CleanShot 2024-07-08 at 20 23 54@2x

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment