Swiftgram/Telegram/BroadcastUpload/BroadcastUploadExtension.swift
2025-05-05 18:04:32 +02:00

573 lines
22 KiB
Swift

import Foundation
import ReplayKit
import CoreVideo
import TelegramVoip
import SwiftSignalKit
import BuildConfig
import BroadcastUploadHelpers
import AudioToolbox
import Postbox
import CoreMedia
import AVFoundation
private func rootPathForBasePath(_ appGroupPath: String) -> String {
return appGroupPath + "/telegram-data"
}
private protocol BroadcastUploadImpl: AnyObject {
func initialize(rootPath: String)
func processVideoSampleBuffer(sampleBuffer: CMSampleBuffer)
func processAudioSampleBuffer(data: Data)
}
private final class InProcessBroadcastUploadImpl: BroadcastUploadImpl {
private weak var extensionContext: RPBroadcastSampleHandler?
private var screencastBufferClientContext: IpcGroupCallBufferBroadcastContext?
private var statusDisposable: Disposable?
init(extensionContext: RPBroadcastSampleHandler) {
self.extensionContext = extensionContext
}
deinit {
self.statusDisposable?.dispose()
}
func initialize(rootPath: String) {
let screencastBufferClientContext = IpcGroupCallBufferBroadcastContext(basePath: rootPath + "/broadcast-coordination")
self.screencastBufferClientContext = screencastBufferClientContext
var wasRunning = false
self.statusDisposable = (screencastBufferClientContext.status
|> deliverOnMainQueue).start(next: { [weak self] status in
guard let self else {
return
}
switch status {
case .active:
wasRunning = true
case let .finished(reason):
if wasRunning {
self.finish(with: .screencastEnded)
} else {
self.finish(with: reason)
}
}
})
}
private func finish(with reason: IpcGroupCallBufferBroadcastContext.Status.FinishReason) {
guard let extensionContext = self.extensionContext else {
return
}
var errorString: String?
switch reason {
case .callEnded:
errorString = "You're not in a voice chat"
case .error:
errorString = "Finished"
case .screencastEnded:
break
}
if let errorString = errorString {
let error = NSError(domain: "BroadcastUploadExtension", code: 1, userInfo: [
NSLocalizedDescriptionKey: errorString
])
extensionContext.finishBroadcastWithError(error)
} else {
finishBroadcastGracefully(extensionContext)
}
}
func processVideoSampleBuffer(sampleBuffer: CMSampleBuffer) {
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
return
}
var orientation = CGImagePropertyOrientation.up
if #available(iOS 11.0, *) {
if let orientationAttachment = CMGetAttachment(sampleBuffer, key: RPVideoSampleOrientationKey as CFString, attachmentModeOut: nil) as? NSNumber {
orientation = CGImagePropertyOrientation(rawValue: orientationAttachment.uint32Value) ?? .up
}
}
if let data = serializePixelBuffer(buffer: pixelBuffer) {
self.screencastBufferClientContext?.setCurrentFrame(data: data, orientation: orientation)
}
}
func processAudioSampleBuffer(data: Data) {
self.screencastBufferClientContext?.writeAudioData(data: data)
}
}
private final class EmbeddedBroadcastUploadImpl: BroadcastUploadImpl {
private weak var extensionContext: RPBroadcastSampleHandler?
private var clientContext: IpcGroupCallEmbeddedBroadcastContext?
private var statusDisposable: Disposable?
private var callContextId: UInt32?
private var callContextDidSetJoinResponse: Bool = false
private var callContext: OngoingGroupCallContext?
private let screencastCapturer: OngoingCallVideoCapturer
private var joinPayloadDisposable: Disposable?
private var sampleBuffers: [CMSampleBuffer] = []
private var lastAcceptedTimestamp: Double?
init(extensionContext: RPBroadcastSampleHandler) {
self.extensionContext = extensionContext
self.screencastCapturer = OngoingCallVideoCapturer(isCustom: true)
}
deinit {
self.joinPayloadDisposable?.dispose()
}
func initialize(rootPath: String) {
let clientContext = IpcGroupCallEmbeddedBroadcastContext(basePath: rootPath + "/embedded-broadcast-coordination")
self.clientContext = clientContext
var wasRunning = false
self.statusDisposable = (clientContext.status
|> deliverOnMainQueue).start(next: { [weak self] status in
guard let self else {
return
}
switch status {
case let .active(id, joinResponse):
wasRunning = true
if self.callContextId != id {
if let callContext = self.callContext {
self.callContext = nil
self.callContextId = nil
self.callContextDidSetJoinResponse = false
self.joinPayloadDisposable?.dispose()
self.joinPayloadDisposable = nil
callContext.stop(account: nil, reportCallId: nil, debugLog: Promise())
}
}
if let id {
if self.callContext == nil {
self.callContextId = id
let callContext = OngoingGroupCallContext(
audioSessionActive: .single(true),
video: self.screencastCapturer,
requestMediaChannelDescriptions: { _, _ in EmptyDisposable },
rejoinNeeded: { },
outgoingAudioBitrateKbit: nil,
videoContentType: .screencast,
enableNoiseSuppression: false,
disableAudioInput: true,
enableSystemMute: false,
prioritizeVP8: false,
logPath: "",
onMutedSpeechActivityDetected: { _ in },
isConference: false,
audioIsActiveByDefault: true,
isStream: false,
sharedAudioDevice: nil,
encryptionContext: nil
)
self.callContext = callContext
self.joinPayloadDisposable = (callContext.joinPayload
|> deliverOnMainQueue).start(next: { [weak self] joinPayload in
guard let self else {
return
}
if self.callContextId != id {
return
}
self.clientContext?.joinPayload = IpcGroupCallEmbeddedAppContext.JoinPayload(
id: id,
data: joinPayload.0,
ssrc: joinPayload.1
)
})
}
if let callContext = self.callContext {
if let joinResponse, !self.callContextDidSetJoinResponse {
self.callContextDidSetJoinResponse = true
callContext.setConnectionMode(.rtc, keepBroadcastConnectedIfWasEnabled: false, isUnifiedBroadcast: false)
callContext.setJoinResponse(payload: joinResponse.data)
}
}
}
case let .finished(reason):
if wasRunning {
self.finish(with: .screencastEnded)
} else {
self.finish(with: reason)
}
}
})
}
private func finish(with reason: IpcGroupCallEmbeddedBroadcastContext.Status.FinishReason) {
guard let extensionContext = self.extensionContext else {
return
}
var errorString: String?
switch reason {
case .callEnded:
errorString = "You're not in a voice chat"
case .error:
errorString = "Finished"
case .screencastEnded:
break
}
if let errorString = errorString {
let error = NSError(domain: "BroadcastUploadExtension", code: 1, userInfo: [
NSLocalizedDescriptionKey: errorString
])
extensionContext.finishBroadcastWithError(error)
} else {
finishBroadcastGracefully(extensionContext)
}
}
func processVideoSampleBuffer(sampleBuffer: CMSampleBuffer) {
let timestamp = CMSampleBufferGetPresentationTimeStamp(sampleBuffer).seconds
if let lastAcceptedTimestamp = self.lastAcceptedTimestamp {
if lastAcceptedTimestamp + 1.0 / 30.0 > timestamp {
return
}
}
self.lastAcceptedTimestamp = timestamp
guard let sourceImageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
return
}
let sourcePixelBuffer: CVPixelBuffer = sourceImageBuffer as CVPixelBuffer
let width = CVPixelBufferGetWidth(sourcePixelBuffer)
let height = CVPixelBufferGetHeight(sourcePixelBuffer)
let sourceBytesPerRow = CVPixelBufferGetBytesPerRow(sourcePixelBuffer)
var outputPixelBuffer: CVPixelBuffer?
let pixelFormat = CVPixelBufferGetPixelFormatType(sourcePixelBuffer)
CVPixelBufferCreate(nil, width, height, pixelFormat, nil, &outputPixelBuffer)
guard let outputPixelBuffer else {
return
}
CVPixelBufferLockBaseAddress(sourcePixelBuffer, [])
CVPixelBufferLockBaseAddress(outputPixelBuffer, [])
let outputBytesPerRow = CVPixelBufferGetBytesPerRow(outputPixelBuffer)
let sourceBaseAddress = CVPixelBufferGetBaseAddress(sourcePixelBuffer)
let outputBaseAddress = CVPixelBufferGetBaseAddress(outputPixelBuffer)
if outputBytesPerRow == sourceBytesPerRow {
memcpy(outputBaseAddress!, sourceBaseAddress!, height * outputBytesPerRow)
} else {
for y in 0 ..< height {
memcpy(outputBaseAddress!.advanced(by: y * outputBytesPerRow), sourceBaseAddress!.advanced(by: y * sourceBytesPerRow), min(sourceBytesPerRow, outputBytesPerRow))
}
}
defer {
CVPixelBufferUnlockBaseAddress(sourcePixelBuffer, [])
CVPixelBufferUnlockBaseAddress(outputPixelBuffer, [])
}
var orientation = CGImagePropertyOrientation.up
if #available(iOS 11.0, *) {
if let orientationAttachment = CMGetAttachment(sampleBuffer, key: RPVideoSampleOrientationKey as CFString, attachmentModeOut: nil) as? NSNumber {
orientation = CGImagePropertyOrientation(rawValue: orientationAttachment.uint32Value) ?? .up
}
}
if let outputSampleBuffer = sampleBufferFromPixelBuffer(pixelBuffer: outputPixelBuffer) {
let semaphore = DispatchSemaphore(value: 0)
self.screencastCapturer.injectSampleBuffer(outputSampleBuffer, rotation: orientation, completion: {
//semaphore.signal()
})
let _ = semaphore.wait(timeout: DispatchTime.now() + 1.0 / 30.0)
}
}
func processAudioSampleBuffer(data: Data) {
self.callContext?.addExternalAudioData(data: data)
}
}
@available(iOS 10.0, *)
@objc(BroadcastUploadSampleHandler) class BroadcastUploadSampleHandler: RPBroadcastSampleHandler {
private var impl: BroadcastUploadImpl?
private var audioConverter: CustomAudioConverter?
public override func beginRequest(with context: NSExtensionContext) {
super.beginRequest(with: context)
}
private func finishWithError() {
let errorString = "Finished"
let error = NSError(domain: "BroadcastUploadExtension", code: 1, userInfo: [
NSLocalizedDescriptionKey: errorString
])
self.finishBroadcastWithError(error)
}
override public func broadcastStarted(withSetupInfo setupInfo: [String : NSObject]?) {
guard let appBundleIdentifier = Bundle.main.bundleIdentifier, let lastDotRange = appBundleIdentifier.range(of: ".", options: [.backwards]) else {
self.finishWithError()
return
}
let baseAppBundleId = String(appBundleIdentifier[..<lastDotRange.lowerBound])
let appGroupName = "group.\(baseAppBundleId)"
let maybeAppGroupUrl = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupName)
guard let appGroupUrl = maybeAppGroupUrl else {
self.finishWithError()
return
}
let rootPath = rootPathForBasePath(appGroupUrl.path)
TempBox.initializeShared(basePath: rootPath, processType: "share", launchSpecificId: Int64.random(in: Int64.min ... Int64.max))
let logsPath = rootPath + "/logs/broadcast-logs"
let _ = try? FileManager.default.createDirectory(atPath: logsPath, withIntermediateDirectories: true, attributes: nil)
let embeddedBroadcastImplementationTypePath = rootPath + "/broadcast-coordination-type-v2"
var useIPCContext = false
if let typeData = try? Data(contentsOf: URL(fileURLWithPath: embeddedBroadcastImplementationTypePath)), let type = String(data: typeData, encoding: .utf8) {
useIPCContext = type == "ipc"
}
let impl: BroadcastUploadImpl
if useIPCContext {
impl = EmbeddedBroadcastUploadImpl(extensionContext: self)
} else {
impl = InProcessBroadcastUploadImpl(extensionContext: self)
}
self.impl = impl
impl.initialize(rootPath: rootPath)
}
override public func broadcastPaused() {
}
override public func broadcastResumed() {
}
override public func broadcastFinished() {
}
override public func processSampleBuffer(_ sampleBuffer: CMSampleBuffer, with sampleBufferType: RPSampleBufferType) {
switch sampleBufferType {
case RPSampleBufferType.video:
processVideoSampleBuffer(sampleBuffer: sampleBuffer)
case RPSampleBufferType.audioApp:
processAudioSampleBuffer(sampleBuffer: sampleBuffer)
case RPSampleBufferType.audioMic:
break
@unknown default:
break
}
}
private func processVideoSampleBuffer(sampleBuffer: CMSampleBuffer) {
self.impl?.processVideoSampleBuffer(sampleBuffer: sampleBuffer)
}
private func processAudioSampleBuffer(sampleBuffer: CMSampleBuffer) {
guard let formatDescription = CMSampleBufferGetFormatDescription(sampleBuffer) else {
return
}
guard let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(formatDescription) else {
return
}
let format = CustomAudioConverter.Format(
numChannels: Int(asbd.pointee.mChannelsPerFrame),
sampleRate: Int(asbd.pointee.mSampleRate)
)
if self.audioConverter?.format != format {
self.audioConverter = CustomAudioConverter(asbd: asbd)
}
if let audioConverter = self.audioConverter {
if let data = audioConverter.convert(sampleBuffer: sampleBuffer), !data.isEmpty {
self.impl?.processAudioSampleBuffer(data: data)
}
}
}
}
private final class CustomAudioConverter {
struct Format: Equatable {
let numChannels: Int
let sampleRate: Int
}
let format: Format
var currentInputDescription: UnsafePointer<AudioStreamBasicDescription>?
var currentBuffer: AudioBuffer?
var currentBufferOffset: UInt32 = 0
init(asbd: UnsafePointer<AudioStreamBasicDescription>) {
self.format = Format(
numChannels: Int(asbd.pointee.mChannelsPerFrame),
sampleRate: Int(asbd.pointee.mSampleRate)
)
}
func convert(sampleBuffer: CMSampleBuffer) -> Data? {
guard let formatDescription = CMSampleBufferGetFormatDescription(sampleBuffer) else {
return nil
}
guard let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(formatDescription) else {
return nil
}
var bufferList = AudioBufferList()
var blockBuffer: CMBlockBuffer? = nil
CMSampleBufferGetAudioBufferListWithRetainedBlockBuffer(
sampleBuffer,
bufferListSizeNeededOut: nil,
bufferListOut: &bufferList,
bufferListSize: MemoryLayout<AudioBufferList>.size,
blockBufferAllocator: nil,
blockBufferMemoryAllocator: nil,
flags: kCMSampleBufferFlag_AudioBufferList_Assure16ByteAlignment,
blockBufferOut: &blockBuffer
)
let size = bufferList.mBuffers.mDataByteSize
guard size != 0, let mData = bufferList.mBuffers.mData else {
return nil
}
var outputDescription = AudioStreamBasicDescription(
mSampleRate: 48000.0,
mFormatID: kAudioFormatLinearPCM,
mFormatFlags: kAudioFormatFlagIsSignedInteger | kAudioFormatFlagsNativeEndian | kAudioFormatFlagIsPacked,
mBytesPerPacket: 2,
mFramesPerPacket: 1,
mBytesPerFrame: 2,
mChannelsPerFrame: 1,
mBitsPerChannel: 16,
mReserved: 0
)
var maybeAudioConverter: AudioConverterRef?
let _ = AudioConverterNew(asbd, &outputDescription, &maybeAudioConverter)
guard let audioConverter = maybeAudioConverter else {
return nil
}
self.currentBuffer = AudioBuffer(
mNumberChannels: asbd.pointee.mChannelsPerFrame,
mDataByteSize: UInt32(size),
mData: mData
)
self.currentBufferOffset = 0
self.currentInputDescription = asbd
var numPackets: UInt32?
let outputSize = 32768 * 2
var outputBuffer = Data(count: outputSize)
outputBuffer.withUnsafeMutableBytes { (outputBytes: UnsafeMutableRawBufferPointer) -> Void in
var outputBufferList = AudioBufferList()
outputBufferList.mNumberBuffers = 1
outputBufferList.mBuffers.mNumberChannels = outputDescription.mChannelsPerFrame
outputBufferList.mBuffers.mDataByteSize = UInt32(outputSize)
outputBufferList.mBuffers.mData = outputBytes.baseAddress!
var outputDataPacketSize = UInt32(outputSize) / outputDescription.mBytesPerPacket
let result = AudioConverterFillComplexBuffer(
audioConverter,
converterComplexInputDataProc,
Unmanaged.passUnretained(self).toOpaque(),
&outputDataPacketSize,
&outputBufferList,
nil
)
if result == noErr {
numPackets = outputDataPacketSize
}
}
AudioConverterDispose(audioConverter)
if let numPackets = numPackets {
outputBuffer.count = Int(numPackets * outputDescription.mBytesPerPacket)
return outputBuffer
} else {
return nil
}
}
}
private func converterComplexInputDataProc(inAudioConverter: AudioConverterRef, ioNumberDataPackets: UnsafeMutablePointer<UInt32>, ioData: UnsafeMutablePointer<AudioBufferList>, ioDataPacketDescription: UnsafeMutablePointer<UnsafeMutablePointer<AudioStreamPacketDescription>?>?, inUserData: UnsafeMutableRawPointer?) -> Int32 {
guard let inUserData = inUserData else {
ioNumberDataPackets.pointee = 0
return 0
}
let instance = Unmanaged<CustomAudioConverter>.fromOpaque(inUserData).takeUnretainedValue()
guard let currentBuffer = instance.currentBuffer else {
ioNumberDataPackets.pointee = 0
return 0
}
guard let currentInputDescription = instance.currentInputDescription else {
ioNumberDataPackets.pointee = 0
return 0
}
let numPacketsInBuffer = currentBuffer.mDataByteSize / currentInputDescription.pointee.mBytesPerPacket
let numPacketsAvailable = numPacketsInBuffer - instance.currentBufferOffset / currentInputDescription.pointee.mBytesPerPacket
let numPacketsToRead = min(ioNumberDataPackets.pointee, numPacketsAvailable)
ioNumberDataPackets.pointee = numPacketsToRead
ioData.pointee.mNumberBuffers = 1
ioData.pointee.mBuffers.mData = currentBuffer.mData?.advanced(by: Int(instance.currentBufferOffset))
ioData.pointee.mBuffers.mDataByteSize = currentBuffer.mDataByteSize - instance.currentBufferOffset
ioData.pointee.mBuffers.mNumberChannels = currentBuffer.mNumberChannels
instance.currentBufferOffset += numPacketsToRead * currentInputDescription.pointee.mBytesPerPacket
return 0
}
private func sampleBufferFromPixelBuffer(pixelBuffer: CVPixelBuffer) -> CMSampleBuffer? {
var maybeFormat: CMVideoFormatDescription?
let status = CMVideoFormatDescriptionCreateForImageBuffer(allocator: kCFAllocatorDefault, imageBuffer: pixelBuffer, formatDescriptionOut: &maybeFormat)
if status != noErr {
return nil
}
guard let format = maybeFormat else {
return nil
}
var timingInfo = CMSampleTimingInfo(
duration: CMTimeMake(value: 1, timescale: 30),
presentationTimeStamp: CMTimeMake(value: 0, timescale: 30),
decodeTimeStamp: CMTimeMake(value: 0, timescale: 30)
)
var maybeSampleBuffer: CMSampleBuffer?
let bufferStatus = CMSampleBufferCreateReadyWithImageBuffer(allocator: kCFAllocatorDefault, imageBuffer: pixelBuffer, formatDescription: format, sampleTiming: &timingInfo, sampleBufferOut: &maybeSampleBuffer)
if (bufferStatus != noErr) {
return nil
}
guard let sampleBuffer = maybeSampleBuffer else {
return nil
}
let attachments: NSArray = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: true)! as NSArray
let dict: NSMutableDictionary = attachments[0] as! NSMutableDictionary
dict[kCMSampleAttachmentKey_DisplayImmediately as NSString] = true as NSNumber
return sampleBuffer
}