From baf2dbbb64d13d6fa3b786939febe1aebfc5274f Mon Sep 17 00:00:00 2001 From: Peter <> Date: Fri, 12 Jul 2019 22:12:29 +0400 Subject: [PATCH] Video scrubbing preview --- .../FFMpegMediaVideoFrameDecoder.swift | 74 +++- .../Sources/MediaPlayerFramePreview.swift | 131 +++++++ .../Sources/MediaPlayerScrubbingNode.swift | 7 +- .../UniversalSoftwareVideoSource.swift | 368 ++++++++++++++++++ .../project.pbxproj | 12 + .../ChatItemGalleryFooterContentNode.swift | 57 +++ .../ChatVideoGalleryItemScrubberView.swift | 7 + .../UniversalVideoGalleryItem.swift | 32 ++ submodules/ffmpeg/FFMpeg/FFMpegAVFrame.h | 6 + submodules/ffmpeg/FFMpeg/FFMpegAVFrame.m | 10 + submodules/ffmpeg/FFMpeg/build-ffmpeg.sh | 6 +- .../FFMpeg_Xcode.xcodeproj/project.pbxproj | 4 + 12 files changed, 708 insertions(+), 6 deletions(-) create mode 100644 submodules/MediaPlayer/Sources/MediaPlayerFramePreview.swift create mode 100644 submodules/MediaPlayer/Sources/UniversalSoftwareVideoSource.swift diff --git a/submodules/MediaPlayer/Sources/FFMpegMediaVideoFrameDecoder.swift b/submodules/MediaPlayer/Sources/FFMpegMediaVideoFrameDecoder.swift index 29c84c0193..0bfdb1731f 100644 --- a/submodules/MediaPlayer/Sources/FFMpegMediaVideoFrameDecoder.swift +++ b/submodules/MediaPlayer/Sources/FFMpegMediaVideoFrameDecoder.swift @@ -1,9 +1,22 @@ import CoreMedia import Accelerate import FFMpeg +import Accelerate private let bufferCount = 32 +private let deviceColorSpace: CGColorSpace = { + if #available(iOSApplicationExtension 9.3, iOS 9.3, *) { + if let colorSpace = CGColorSpace(name: CGColorSpace.displayP3) { + return colorSpace + } else { + return CGColorSpaceCreateDeviceRGB() + } + } else { + return CGColorSpaceCreateDeviceRGB() + } +}() + public final class FFMpegMediaVideoFrameDecoder: MediaTrackFrameDecoder { private let codecContext: FFMpegAVCodecContext @@ -63,6 +76,17 @@ public final class FFMpegMediaVideoFrameDecoder: MediaTrackFrameDecoder { return nil } + public func render(frame: MediaTrackDecodableFrame) -> UIImage? { + let status = frame.packet.send(toDecoder: self.codecContext) + if status == 0 { + if self.codecContext.receive(into: self.videoFrame) { + return convertVideoFrameToImage(self.videoFrame) + } + } + + return nil + } + public func takeRemainingFrame() -> MediaTrackFrame? { if !self.delayedFrames.isEmpty { var minFrameIndex = 0 @@ -79,6 +103,53 @@ public final class FFMpegMediaVideoFrameDecoder: MediaTrackFrameDecoder { } } + private func convertVideoFrameToImage(_ frame: FFMpegAVFrame) -> UIImage? { + var info = vImage_YpCbCrToARGB() + + var pixelRange: vImage_YpCbCrPixelRange + switch frame.colorRange { + case .full: + pixelRange = vImage_YpCbCrPixelRange(Yp_bias: 0, CbCr_bias: 128, YpRangeMax: 255, CbCrRangeMax: 255, YpMax: 255, YpMin: 0, CbCrMax: 255, CbCrMin: 0) + default: + pixelRange = vImage_YpCbCrPixelRange(Yp_bias: 16, CbCr_bias: 128, YpRangeMax: 235, CbCrRangeMax: 240, YpMax: 255, YpMin: 0, CbCrMax: 255, CbCrMin: 0) + } + var result = kvImageNoError + result = vImageConvert_YpCbCrToARGB_GenerateConversion(kvImage_YpCbCrToARGBMatrix_ITU_R_709_2, &pixelRange, &info, kvImage420Yp8_Cb8_Cr8, kvImageARGB8888, 0) + if result != kvImageNoError { + return nil + } + + var srcYp = vImage_Buffer(data: frame.data[0], height: vImagePixelCount(frame.height), width: vImagePixelCount(frame.width), rowBytes: Int(frame.lineSize[0])) + var srcCb = vImage_Buffer(data: frame.data[1], height: vImagePixelCount(frame.height), width: vImagePixelCount(frame.width / 2), rowBytes: Int(frame.lineSize[1])) + var srcCr = vImage_Buffer(data: frame.data[2], height: vImagePixelCount(frame.height), width: vImagePixelCount(frame.width / 2), rowBytes: Int(frame.lineSize[2])) + + let argbBytesPerRow = (4 * Int(frame.width) + 15) & (~15) + let argbLength = argbBytesPerRow * Int(frame.height) + let argb = malloc(argbLength)! + guard let provider = CGDataProvider(dataInfo: argb, data: argb, size: argbLength, releaseData: { bytes, _, _ in + free(bytes) + }) else { + return nil + } + + var dst = vImage_Buffer(data: argb, height: vImagePixelCount(frame.height), width: vImagePixelCount(frame.width), rowBytes: argbBytesPerRow) + + var permuteMap: [UInt8] = [3, 2, 1, 0] + + result = vImageConvert_420Yp8_Cb8_Cr8ToARGB8888(&srcYp, &srcCb, &srcCr, &dst, &info, &permuteMap, 0x00, 0) + if result != kvImageNoError { + return nil + } + + let bitmapInfo = CGBitmapInfo(rawValue: CGBitmapInfo.byteOrder32Little.rawValue | CGImageAlphaInfo.noneSkipFirst.rawValue) + + guard let image = CGImage(width: Int(frame.width), height: Int(frame.height), bitsPerComponent: 8, bitsPerPixel: 32, bytesPerRow: argbBytesPerRow, space: deviceColorSpace, bitmapInfo: bitmapInfo, provider: provider, decode: nil, shouldInterpolate: false, intent: .defaultIntent) else { + return nil + } + + return UIImage(cgImage: image, scale: 1.0, orientation: .up) + } + private func convertVideoFrame(_ frame: FFMpegAVFrame, pts: CMTime, dts: CMTime, duration: CMTime) -> MediaTrackFrame? { if frame.data[0] == nil { return nil @@ -100,9 +171,6 @@ public final class FFMpegMediaVideoFrameDecoder: MediaTrackFrameDecoder { ioSurfaceProperties["IOSurfaceIsGlobal"] = true as NSNumber var options: [String: Any] = [kCVPixelBufferBytesPerRowAlignmentKey as String: frame.lineSize[0] as NSNumber] - /*if #available(iOSApplicationExtension 9.0, iOS 9.0, *) { - options[kCVPixelBufferOpenGLESTextureCacheCompatibilityKey as String] = true as NSNumber - }*/ options[kCVPixelBufferIOSurfacePropertiesKey as String] = ioSurfaceProperties CVPixelBufferCreate(kCFAllocatorDefault, diff --git a/submodules/MediaPlayer/Sources/MediaPlayerFramePreview.swift b/submodules/MediaPlayer/Sources/MediaPlayerFramePreview.swift new file mode 100644 index 0000000000..883595ab89 --- /dev/null +++ b/submodules/MediaPlayer/Sources/MediaPlayerFramePreview.swift @@ -0,0 +1,131 @@ +import Foundation +import SwiftSignalKit +import Postbox +import TelegramCore +import FFMpeg + +private final class FramePreviewContext { + let source: UniversalSoftwareVideoSource + + init(source: UniversalSoftwareVideoSource) { + self.source = source + } +} + +private func initializedPreviewContext(queue: Queue, postbox: Postbox, fileReference: FileMediaReference) -> Signal, NoError> { + return Signal { subscriber in + let source = UniversalSoftwareVideoSource(mediaBox: postbox.mediaBox, fileReference: fileReference) + let readyDisposable = (source.ready + |> filter { $0 }).start(next: { _ in + subscriber.putNext(QueueLocalObject(queue: queue, generate: { + return FramePreviewContext(source: source) + })) + }) + + return ActionDisposable { + readyDisposable.dispose() + } + } +} + +private final class MediaPlayerFramePreviewImpl { + private let queue: Queue + private let context: Promise> + private let currentFrameDisposable = MetaDisposable() + private var currentFrameTimestamp: Double? + private var nextFrameTimestamp: Double? + fileprivate let framePipe = ValuePipe() + + init(queue: Queue, postbox: Postbox, fileReference: FileMediaReference) { + self.queue = queue + self.context = Promise() + self.context.set(initializedPreviewContext(queue: queue, postbox: postbox, fileReference: fileReference)) + } + + deinit { + assert(self.queue.isCurrent()) + self.currentFrameDisposable.dispose() + } + + func generateFrame(at timestamp: Double) { + if self.currentFrameTimestamp != nil { + self.nextFrameTimestamp = timestamp + return + } + self.currentFrameTimestamp = timestamp + + let queue = self.queue + let takeDisposable = MetaDisposable() + let disposable = (self.context.get() + |> take(1)).start(next: { [weak self] context in + queue.async { + guard context.queue === queue else { + return + } + context.with { context in + let disposable = context.source.takeFrame(at: timestamp).start(next: { image in + guard let strongSelf = self else { + return + } + if let image = image { + strongSelf.framePipe.putNext(image) + } + strongSelf.currentFrameTimestamp = nil + if let nextFrameTimestamp = strongSelf.nextFrameTimestamp { + strongSelf.nextFrameTimestamp = nil + strongSelf.generateFrame(at: nextFrameTimestamp) + } + }) + takeDisposable.set(disposable) + } + } + }) + self.currentFrameDisposable.set(ActionDisposable { + takeDisposable.dispose() + disposable.dispose() + }) + } + + func cancelPendingFrames() { + self.nextFrameTimestamp = nil + self.currentFrameTimestamp = nil + self.currentFrameDisposable.set(nil) + } +} + +public final class MediaPlayerFramePreview { + private let queue: Queue + private let impl: QueueLocalObject + + public var generatedFrames: Signal { + return Signal { subscriber in + let disposable = MetaDisposable() + self.impl.with { impl in + disposable.set(impl.framePipe.signal().start(next: { image in + subscriber.putNext(image) + })) + } + return disposable + } + } + + public init(postbox: Postbox, fileReference: FileMediaReference) { + let queue = Queue() + self.queue = queue + self.impl = QueueLocalObject(queue: queue, generate: { + return MediaPlayerFramePreviewImpl(queue: queue, postbox: postbox, fileReference: fileReference) + }) + } + + public func generateFrame(at timestamp: Double) { + self.impl.with { impl in + impl.generateFrame(at: timestamp) + } + } + + public func cancelPendingFrames() { + self.impl.with { impl in + impl.cancelPendingFrames() + } + } +} diff --git a/submodules/MediaPlayer/Sources/MediaPlayerScrubbingNode.swift b/submodules/MediaPlayer/Sources/MediaPlayerScrubbingNode.swift index e5efcb136c..d7de8daf3e 100644 --- a/submodules/MediaPlayer/Sources/MediaPlayerScrubbingNode.swift +++ b/submodules/MediaPlayer/Sources/MediaPlayerScrubbingNode.swift @@ -188,6 +188,7 @@ public final class MediaPlayerScrubbingNode: ASDisplayNode { public var playbackStatusUpdated: ((MediaPlayerPlaybackStatus?) -> Void)? public var playerStatusUpdated: ((MediaPlayerStatus?) -> Void)? public var seek: ((Double) -> Void)? + public var update: ((Double?, CGFloat) -> Void)? private let _scrubbingTimestamp = Promise(nil) public var scrubbingTimestamp: Signal { @@ -378,6 +379,7 @@ public final class MediaPlayerScrubbingNode: ASDisplayNode { strongSelf.scrubbingBeginTimestamp = statusValue.timestamp strongSelf.scrubbingTimestampValue = statusValue.timestamp strongSelf._scrubbingTimestamp.set(.single(strongSelf.scrubbingTimestampValue)) + strongSelf.update?(strongSelf.scrubbingTimestampValue, CGFloat(statusValue.timestamp / statusValue.duration)) strongSelf.updateProgressAnimations() } } @@ -385,8 +387,10 @@ public final class MediaPlayerScrubbingNode: ASDisplayNode { handleNodeContainer.updateScrubbing = { [weak self] addedFraction in if let strongSelf = self { if let statusValue = strongSelf.statusValue, let scrubbingBeginTimestamp = strongSelf.scrubbingBeginTimestamp, Double(0.0).isLess(than: statusValue.duration) { - strongSelf.scrubbingTimestampValue = max(0.0, min(statusValue.duration, scrubbingBeginTimestamp + statusValue.duration * Double(addedFraction))) + let timestampValue = max(0.0, min(statusValue.duration, scrubbingBeginTimestamp + statusValue.duration * Double(addedFraction))) + strongSelf.scrubbingTimestampValue = timestampValue strongSelf._scrubbingTimestamp.set(.single(strongSelf.scrubbingTimestampValue)) + strongSelf.update?(timestampValue, CGFloat(timestampValue / statusValue.duration)) strongSelf.updateProgressAnimations() } } @@ -408,6 +412,7 @@ public final class MediaPlayerScrubbingNode: ASDisplayNode { } strongSelf.seek?(scrubbingTimestampValue) } + strongSelf.update?(nil, 0.0) strongSelf.updateProgressAnimations() } } diff --git a/submodules/MediaPlayer/Sources/UniversalSoftwareVideoSource.swift b/submodules/MediaPlayer/Sources/UniversalSoftwareVideoSource.swift new file mode 100644 index 0000000000..040cb8b363 --- /dev/null +++ b/submodules/MediaPlayer/Sources/UniversalSoftwareVideoSource.swift @@ -0,0 +1,368 @@ +import Foundation +import SwiftSignalKit +import Postbox +import TelegramCore +import FFMpeg + +private func readPacketCallback(userData: UnsafeMutableRawPointer?, buffer: UnsafeMutablePointer?, bufferSize: Int32) -> Int32 { + let context = Unmanaged.fromOpaque(userData!).takeUnretainedValue() + let data: Signal + + let resourceSize: Int = context.size + let readCount = min(resourceSize - context.readingOffset, Int(bufferSize)) + let requestRange: Range = context.readingOffset ..< (context.readingOffset + readCount) + + let semaphore = DispatchSemaphore(value: 0) + data = context.mediaBox.resourceData(context.fileReference.media.resource, size: context.size, in: requestRange, mode: .complete) + var fetchedData: Data? + let disposable = data.start(next: { data in + if data.count == readCount { + fetchedData = data + semaphore.signal() + } + }) + let cancelDisposable = context.cancelRead.start(next: { value in + if value { + semaphore.signal() + } + }) + semaphore.wait() + + disposable.dispose() + cancelDisposable.dispose() + + if let fetchedData = fetchedData { + fetchedData.withUnsafeBytes { (bytes: UnsafePointer) -> Void in + memcpy(buffer, bytes, fetchedData.count) + } + let fetchedCount = Int32(fetchedData.count) + context.readingOffset += Int(fetchedCount) + return fetchedCount + } else { + return 0 + } +} + +private func seekCallback(userData: UnsafeMutableRawPointer?, offset: Int64, whence: Int32) -> Int64 { + let context = Unmanaged.fromOpaque(userData!).takeUnretainedValue() + if (whence & FFMPEG_AVSEEK_SIZE) != 0 { + return Int64(context.size) + } else { + context.readingOffset = Int(offset) + return offset + } +} + +private final class SoftwareVideoStream { + let index: Int + let fps: CMTime + let timebase: CMTime + let duration: CMTime + let decoder: FFMpegMediaVideoFrameDecoder + let rotationAngle: Double + let aspect: Double + + init(index: Int, fps: CMTime, timebase: CMTime, duration: CMTime, decoder: FFMpegMediaVideoFrameDecoder, rotationAngle: Double, aspect: Double) { + self.index = index + self.fps = fps + self.timebase = timebase + self.duration = duration + self.decoder = decoder + self.rotationAngle = rotationAngle + self.aspect = aspect + } +} + +private final class UniversalSoftwareVideoSourceImpl { + fileprivate let mediaBox: MediaBox + fileprivate let fileReference: FileMediaReference + fileprivate let size: Int + + fileprivate let state: ValuePromise + + fileprivate var avIoContext: FFMpegAVIOContext! + fileprivate var avFormatContext: FFMpegAVFormatContext! + fileprivate var videoStream: SoftwareVideoStream! + + fileprivate var readingOffset: Int = 0 + + fileprivate var cancelRead: Signal + + init?(mediaBox: MediaBox, fileReference: FileMediaReference, state: ValuePromise, cancelInitialization: Signal) { + guard let size = fileReference.media.size else { + return nil + } + + self.mediaBox = mediaBox + self.fileReference = fileReference + self.size = size + + self.state = state + state.set(.initializing) + + self.cancelRead = cancelInitialization + + let ioBufferSize = 64 * 1024 + + guard let avIoContext = FFMpegAVIOContext(bufferSize: Int32(ioBufferSize), opaqueContext: Unmanaged.passUnretained(self).toOpaque(), readPacket: readPacketCallback, seek: seekCallback) else { + return nil + } + self.avIoContext = avIoContext + + let avFormatContext = FFMpegAVFormatContext() + avFormatContext.setIO(avIoContext) + + if !avFormatContext.openInput() { + return nil + } + + if !avFormatContext.findStreamInfo() { + return nil + } + + self.avFormatContext = avFormatContext + + var videoStream: SoftwareVideoStream? + + for streamIndexNumber in avFormatContext.streamIndices(for: FFMpegAVFormatStreamTypeVideo) { + let streamIndex = streamIndexNumber.int32Value + if avFormatContext.isAttachedPic(atStreamIndex: streamIndex) { + continue + } + + let codecId = avFormatContext.codecId(atStreamIndex: streamIndex) + + let fpsAndTimebase = avFormatContext.fpsAndTimebase(forStreamIndex: streamIndex, defaultTimeBase: CMTimeMake(value: 1, timescale: 40000)) + let (fps, timebase) = (fpsAndTimebase.fps, fpsAndTimebase.timebase) + + let duration = CMTimeMake(value: avFormatContext.duration(atStreamIndex: streamIndex), timescale: timebase.timescale) + + let metrics = avFormatContext.metricsForStream(at: streamIndex) + + let rotationAngle: Double = metrics.rotationAngle + let aspect = Double(metrics.width) / Double(metrics.height) + + if let codec = FFMpegAVCodec.find(forId: codecId) { + let codecContext = FFMpegAVCodecContext(codec: codec) + if avFormatContext.codecParams(atStreamIndex: streamIndex, to: codecContext) { + if codecContext.open() { + videoStream = SoftwareVideoStream(index: Int(streamIndex), fps: fps, timebase: timebase, duration: duration, decoder: FFMpegMediaVideoFrameDecoder(codecContext: codecContext), rotationAngle: rotationAngle, aspect: aspect) + break + } + } + } + } + + if let videoStream = videoStream { + self.videoStream = videoStream + } else { + return nil + } + + state.set(.ready) + } + + private func readPacketInternal() -> FFMpegPacket? { + guard let avFormatContext = self.avFormatContext else { + return nil + } + + let packet = FFMpegPacket() + if avFormatContext.readFrame(into: packet) { + return packet + } else { + return nil + } + } + + func readDecodableFrame() -> (MediaTrackDecodableFrame?, Bool) { + var frames: [MediaTrackDecodableFrame] = [] + var endOfStream = false + + while frames.isEmpty { + if let packet = self.readPacketInternal() { + if let videoStream = videoStream, Int(packet.streamIndex) == videoStream.index { + let packetPts = packet.pts + + let pts = CMTimeMake(value: packetPts, timescale: videoStream.timebase.timescale) + let dts = CMTimeMake(value: packet.dts, timescale: videoStream.timebase.timescale) + + let duration: CMTime + + let frameDuration = packet.duration + if frameDuration != 0 { + duration = CMTimeMake(value: frameDuration * videoStream.timebase.value, timescale: videoStream.timebase.timescale) + } else { + duration = videoStream.fps + } + + let frame = MediaTrackDecodableFrame(type: .video, packet: packet, pts: pts, dts: dts, duration: duration) + frames.append(frame) + } + } else { + if endOfStream { + break + } else { + if let avFormatContext = self.avFormatContext, let videoStream = self.videoStream { + endOfStream = true + avFormatContext.seekFrame(forStreamIndex: Int32(videoStream.index), pts: 0) + } else { + endOfStream = true + break + } + } + } + } + + if endOfStream { + if let videoStream = self.videoStream { + videoStream.decoder.reset() + } + } + + return (frames.first, endOfStream) + } + + func readImage() -> (UIImage?, CGFloat, CGFloat, Bool) { + if let videoStream = self.videoStream { + for _ in 0 ..< 10 { + let (decodableFrame, loop) = self.readDecodableFrame() + if let decodableFrame = decodableFrame { + if let renderedFrame = videoStream.decoder.render(frame: decodableFrame) { + return (renderedFrame, CGFloat(videoStream.rotationAngle), CGFloat(videoStream.aspect), loop) + } + } + } + return (nil, CGFloat(videoStream.rotationAngle), CGFloat(videoStream.aspect), true) + } else { + return (nil, 0.0, 1.0, false) + } + } + + public func seek(timestamp: Double) { + if let stream = self.videoStream, let avFormatContext = self.avFormatContext { + let pts = CMTimeMakeWithSeconds(timestamp, preferredTimescale: stream.timebase.timescale) + avFormatContext.seekFrame(forStreamIndex: Int32(stream.index), pts: pts.value) + stream.decoder.reset() + } + } +} + +private enum UniversalSoftwareVideoSourceState { + case initializing + case failed + case ready + case generatingFrame +} + +private final class UniversalSoftwareVideoSourceThreadParams: NSObject { + let mediaBox: MediaBox + let fileReference: FileMediaReference + let state: ValuePromise + let cancelInitialization: Signal + + init(mediaBox: MediaBox, fileReference: FileMediaReference, state: ValuePromise, cancelInitialization: Signal) { + self.mediaBox = mediaBox + self.fileReference = fileReference + self.state = state + self.cancelInitialization = cancelInitialization + } +} + +private final class UniversalSoftwareVideoSourceTakeFrameParams: NSObject { + let timestamp: Double + let completion: (UIImage?) -> Void + let cancel: Signal + + init(timestamp: Double, completion: @escaping (UIImage?) -> Void, cancel: Signal) { + self.timestamp = timestamp + self.completion = completion + self.cancel = cancel + } +} + +private final class UniversalSoftwareVideoSourceThread: NSObject { + @objc static func entryPoint(_ params: UniversalSoftwareVideoSourceThreadParams) { + let runLoop = RunLoop.current + + let timer = Timer(fireAt: .distantFuture, interval: 0.0, target: UniversalSoftwareVideoSourceThread.self, selector: #selector(UniversalSoftwareVideoSourceThread.none), userInfo: nil, repeats: false) + runLoop.add(timer, forMode: .common) + + let source = UniversalSoftwareVideoSourceImpl(mediaBox: params.mediaBox, fileReference: params.fileReference, state: params.state, cancelInitialization: params.cancelInitialization) + Thread.current.threadDictionary["source"] = source + + while true { + runLoop.run(mode: .default, before: .distantFuture) + if Thread.current.threadDictionary["UniversalSoftwareVideoSourceThread_stop"] != nil { + break + } + } + + Thread.current.threadDictionary.removeObject(forKey: "source") + } + + @objc static func none() { + } + + @objc static func stop() { + Thread.current.threadDictionary["UniversalSoftwareVideoSourceThread_stop"] = "true" + } + + @objc static func takeFrame(_ params: UniversalSoftwareVideoSourceTakeFrameParams) { + guard let source = Thread.current.threadDictionary["source"] as? UniversalSoftwareVideoSourceImpl else { + params.completion(nil) + return + } + source.cancelRead = params.cancel + source.state.set(.generatingFrame) + let startTime = CFAbsoluteTimeGetCurrent() + source.seek(timestamp: params.timestamp) + let image = source.readImage().0 + params.completion(image) + source.state.set(.ready) + print("take frame: \(CFAbsoluteTimeGetCurrent() - startTime) s") + } +} + +final class UniversalSoftwareVideoSource { + private let thread: Thread + private let stateValue: ValuePromise = ValuePromise(.initializing, ignoreRepeated: true) + private let cancelInitialization: ValuePromise = ValuePromise(false) + + var ready: Signal { + return self.stateValue.get() + |> map { value -> Bool in + switch value { + case .ready: + return true + default: + return false + } + } + } + + init(mediaBox: MediaBox, fileReference: FileMediaReference) { + self.thread = Thread(target: UniversalSoftwareVideoSourceThread.self, selector: #selector(UniversalSoftwareVideoSourceThread.entryPoint(_:)), object: UniversalSoftwareVideoSourceThreadParams(mediaBox: mediaBox, fileReference: fileReference, state: self.stateValue, cancelInitialization: self.cancelInitialization.get())) + self.thread.name = "UniversalSoftwareVideoSource" + self.thread.start() + } + + deinit { + UniversalSoftwareVideoSourceThread.self.perform(#selector(UniversalSoftwareVideoSourceThread.stop), on: self.thread, with: nil, waitUntilDone: false) + self.cancelInitialization.set(true) + } + + public func takeFrame(at timestamp: Double) -> Signal { + return Signal { subscriber in + let cancel = ValuePromise(false) + UniversalSoftwareVideoSourceThread.self.perform(#selector(UniversalSoftwareVideoSourceThread.takeFrame(_:)), on: self.thread, with: UniversalSoftwareVideoSourceTakeFrameParams(timestamp: timestamp, completion: { image in + subscriber.putNext(image) + subscriber.putCompletion() + }, cancel: cancel.get()), waitUntilDone: false) + + return ActionDisposable { + cancel.set(true) + } + } + } +} diff --git a/submodules/MediaPlayer/UniversalMediaPlayer_Xcode.xcodeproj/project.pbxproj b/submodules/MediaPlayer/UniversalMediaPlayer_Xcode.xcodeproj/project.pbxproj index 41bf15175f..9c8083ab4a 100644 --- a/submodules/MediaPlayer/UniversalMediaPlayer_Xcode.xcodeproj/project.pbxproj +++ b/submodules/MediaPlayer/UniversalMediaPlayer_Xcode.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + D03B054022D8866A0000BE1A /* MediaPlayerFramePreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = D03B053F22D8866A0000BE1A /* MediaPlayerFramePreview.swift */; }; + D03B054222D888A00000BE1A /* SoftwareVideoSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D03B054122D888A00000BE1A /* SoftwareVideoSource.swift */; }; D0750C6E22B28E6600BE5F6E /* RingBuffer.m in Sources */ = {isa = PBXBuildFile; fileRef = D0750C6B22B28E6500BE5F6E /* RingBuffer.m */; }; D0750C6F22B28E6600BE5F6E /* RingByteBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0750C6C22B28E6600BE5F6E /* RingByteBuffer.swift */; }; D0750C7022B28E6600BE5F6E /* RingBuffer.h in Headers */ = {isa = PBXBuildFile; fileRef = D0750C6D22B28E6600BE5F6E /* RingBuffer.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -37,9 +39,12 @@ D0AE325B22B286A70058D3BC /* MediaTrackDecodableFrame.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0AE324922B286A70058D3BC /* MediaTrackDecodableFrame.swift */; }; D0AE325C22B286A70058D3BC /* MediaTrackFrame.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0AE324A22B286A70058D3BC /* MediaTrackFrame.swift */; }; D0AE325E22B286C30058D3BC /* AVFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D0AE325D22B286C30058D3BC /* AVFoundation.framework */; }; + D0E8B10C22D8B7E800C82570 /* UniversalSoftwareVideoSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E8B10B22D8B7E800C82570 /* UniversalSoftwareVideoSource.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + D03B053F22D8866A0000BE1A /* MediaPlayerFramePreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlayerFramePreview.swift; sourceTree = ""; }; + D03B054122D888A00000BE1A /* SoftwareVideoSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SoftwareVideoSource.swift; sourceTree = ""; }; D0750C6B22B28E6500BE5F6E /* RingBuffer.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RingBuffer.m; sourceTree = ""; }; D0750C6C22B28E6600BE5F6E /* RingByteBuffer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RingByteBuffer.swift; sourceTree = ""; }; D0750C6D22B28E6600BE5F6E /* RingBuffer.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RingBuffer.h; sourceTree = ""; }; @@ -72,6 +77,7 @@ D0AE324922B286A70058D3BC /* MediaTrackDecodableFrame.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaTrackDecodableFrame.swift; sourceTree = ""; }; D0AE324A22B286A70058D3BC /* MediaTrackFrame.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaTrackFrame.swift; sourceTree = ""; }; D0AE325D22B286C30058D3BC /* AVFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AVFoundation.framework; path = System/Library/Frameworks/AVFoundation.framework; sourceTree = SDKROOT; }; + D0E8B10B22D8B7E800C82570 /* UniversalSoftwareVideoSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UniversalSoftwareVideoSource.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -135,7 +141,10 @@ D0AE323E22B286A60058D3BC /* MediaTrackFrameBuffer.swift */, D0AE324222B286A60058D3BC /* MediaTrackFrameDecoder.swift */, D0AE323C22B286A50058D3BC /* VideoPlayerProxy.swift */, + D03B054122D888A00000BE1A /* SoftwareVideoSource.swift */, + D03B053F22D8866A0000BE1A /* MediaPlayerFramePreview.swift */, D0AE322222B285F70058D3BC /* UniversalMediaPlayer.h */, + D0E8B10B22D8B7E800C82570 /* UniversalSoftwareVideoSource.swift */, ); path = Sources; sourceTree = ""; @@ -241,6 +250,7 @@ D0AE325A22B286A70058D3BC /* FFMpegMediaVideoFrameDecoder.swift in Sources */, D0750C6E22B28E6600BE5F6E /* RingBuffer.m in Sources */, D0AE325422B286A70058D3BC /* MediaTrackFrameDecoder.swift in Sources */, + D0E8B10C22D8B7E800C82570 /* UniversalSoftwareVideoSource.swift in Sources */, D0AE325322B286A70058D3BC /* MediaPlayerNode.swift in Sources */, D0AE325122B286A70058D3BC /* MediaPlayer.swift in Sources */, D0AE325722B286A70058D3BC /* MediaPlaybackData.swift in Sources */, @@ -251,11 +261,13 @@ D0AE325222B286A70058D3BC /* FFMpegMediaFrameSourceContext.swift in Sources */, D0AE324E22B286A70058D3BC /* VideoPlayerProxy.swift in Sources */, D0AE325022B286A70058D3BC /* MediaTrackFrameBuffer.swift in Sources */, + D03B054022D8866A0000BE1A /* MediaPlayerFramePreview.swift in Sources */, D0AE325C22B286A70058D3BC /* MediaTrackFrame.swift in Sources */, D0AE324F22B286A70058D3BC /* MediaPlayerTimeTextNode.swift in Sources */, D0750C6F22B28E6600BE5F6E /* RingByteBuffer.swift in Sources */, D0AE325822B286A70058D3BC /* FFMpegMediaPassthroughVideoFrameDecoder.swift in Sources */, D0AE325B22B286A70058D3BC /* MediaTrackDecodableFrame.swift in Sources */, + D03B054222D888A00000BE1A /* SoftwareVideoSource.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/submodules/TelegramUI/TelegramUI/ChatItemGalleryFooterContentNode.swift b/submodules/TelegramUI/TelegramUI/ChatItemGalleryFooterContentNode.swift index 7629d635e4..82f1eb905b 100644 --- a/submodules/TelegramUI/TelegramUI/ChatItemGalleryFooterContentNode.swift +++ b/submodules/TelegramUI/TelegramUI/ChatItemGalleryFooterContentNode.swift @@ -167,6 +167,10 @@ final class ChatItemGalleryFooterContentNode: GalleryFooterContentNode, UIScroll private let messageContextDisposable = MetaDisposable() + private var videoFramePreviewNode: ASImageNode? + + private var validLayout: (CGSize, LayoutMetrics, CGFloat, CGFloat, CGFloat, CGFloat)? + var playbackControl: (() -> Void)? var seekBackward: (() -> Void)? var seekForward: (() -> Void)? @@ -225,6 +229,8 @@ final class ChatItemGalleryFooterContentNode: GalleryFooterContentNode, UIScroll } } + private var scrubbingHandleRelativePosition: CGFloat = 0.0 + var scrubberView: ChatVideoGalleryItemScrubberView? = nil { willSet { if let scrubberView = self.scrubberView, scrubberView.superview == self.view { @@ -234,6 +240,15 @@ final class ChatItemGalleryFooterContentNode: GalleryFooterContentNode, UIScroll didSet { if let scrubberView = self.scrubberView { self.view.addSubview(scrubberView) + scrubberView.updateScrubbingHandlePosition = { [weak self] value in + guard let strongSelf = self else { + return + } + strongSelf.scrubbingHandleRelativePosition = value + if let validLayout = strongSelf.validLayout { + let _ = strongSelf.updateLayout(size: validLayout.0, metrics: validLayout.1, leftInset: validLayout.2, rightInset: validLayout.3, bottomInset: validLayout.4, contentInset: validLayout.5, transition: .immediate) + } + } } } } @@ -500,6 +515,8 @@ final class ChatItemGalleryFooterContentNode: GalleryFooterContentNode, UIScroll } override func updateLayout(size: CGSize, metrics: LayoutMetrics, leftInset: CGFloat, rightInset: CGFloat, bottomInset: CGFloat, contentInset: CGFloat, transition: ContainedViewLayoutTransition) -> CGFloat { + self.validLayout = (size, metrics, leftInset, rightInset, bottomInset, contentInset) + let width = size.width var bottomInset = bottomInset if !bottomInset.isZero && bottomInset < 30.0 { @@ -621,6 +638,16 @@ final class ChatItemGalleryFooterContentNode: GalleryFooterContentNode, UIScroll self.dateNode.frame = CGRect(origin: CGPoint(x: floor((width - dateSize.width) / 2.0), y: panelHeight - bottomInset - 44.0 + floor((44.0 - dateSize.height - authorNameSize.height - labelsSpacing) / 2.0) + authorNameSize.height + labelsSpacing), size: dateSize) } + if let videoFramePreviewNode = self.videoFramePreviewNode { + let intrinsicImageSize = videoFramePreviewNode.image?.size ?? CGSize(width: 320.0, height: 240.0) + let imageSize = intrinsicImageSize.aspectFitted(CGSize(width: 200.0, height: 200.0)) + var imageFrame = CGRect(origin: CGPoint(x: leftInset + floor(self.scrubbingHandleRelativePosition * (width - leftInset - rightInset) - imageSize.width / 2.0), y: self.scrollNode.frame.minY - 10.0 - imageSize.height), size: imageSize) + imageFrame.origin.x = min(imageFrame.origin.x, width - rightInset - 10.0 - imageSize.width) + imageFrame.origin.x = max(imageFrame.origin.x, leftInset + 10.0) + + videoFramePreviewNode.frame = imageFrame + } + return panelHeight } @@ -993,4 +1020,34 @@ final class ChatItemGalleryFooterContentNode: GalleryFooterContentNode, UIScroll @objc private func statusPressed() { self.fetchControl?() } + + func setFramePreviewImage(image: UIImage?) { + if let image = image { + let videoFramePreviewNode: ASImageNode + var animateIn = false + if let current = self.videoFramePreviewNode { + videoFramePreviewNode = current + } else { + videoFramePreviewNode = ASImageNode() + videoFramePreviewNode.displaysAsynchronously = false + videoFramePreviewNode.displayWithoutProcessing = true + self.videoFramePreviewNode = videoFramePreviewNode + self.addSubnode(videoFramePreviewNode) + animateIn = true + } + let updateLayout = videoFramePreviewNode.image?.size != image.size + videoFramePreviewNode.image = image + if updateLayout, let validLayout = self.validLayout { + let _ = self.updateLayout(size: validLayout.0, metrics: validLayout.1, leftInset: validLayout.2, rightInset: validLayout.3, bottomInset: validLayout.4, contentInset: validLayout.5, transition: .immediate) + } + if animateIn { + videoFramePreviewNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1) + } + } else if let videoFramePreviewNode = self.videoFramePreviewNode { + self.videoFramePreviewNode = nil + videoFramePreviewNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.1, removeOnCompletion: false, completion: { [weak videoFramePreviewNode] _ in + videoFramePreviewNode?.removeFromSupernode() + }) + } + } } diff --git a/submodules/TelegramUI/TelegramUI/ChatVideoGalleryItemScrubberView.swift b/submodules/TelegramUI/TelegramUI/ChatVideoGalleryItemScrubberView.swift index fc13847130..ab588236ca 100644 --- a/submodules/TelegramUI/TelegramUI/ChatVideoGalleryItemScrubberView.swift +++ b/submodules/TelegramUI/TelegramUI/ChatVideoGalleryItemScrubberView.swift @@ -42,6 +42,8 @@ final class ChatVideoGalleryItemScrubberView: UIView { } } + var updateScrubbing: (Double?) -> Void = { _ in } + var updateScrubbingHandlePosition: (CGFloat) -> Void = { _ in } var seek: (Double) -> Void = { _ in } override init(frame: CGRect) { @@ -63,6 +65,11 @@ final class ChatVideoGalleryItemScrubberView: UIView { self?.seek(timestamp) } + self.scrubberNode.update = { [weak self] timestamp, position in + self?.updateScrubbing(timestamp) + self?.updateScrubbingHandlePosition(position) + } + self.scrubberNode.playerStatusUpdated = { [weak self] status in if let strongSelf = self { strongSelf.playbackStatus = status diff --git a/submodules/TelegramUI/TelegramUI/UniversalVideoGalleryItem.swift b/submodules/TelegramUI/TelegramUI/UniversalVideoGalleryItem.swift index 62820724c3..9e1d5c9c1f 100644 --- a/submodules/TelegramUI/TelegramUI/UniversalVideoGalleryItem.swift +++ b/submodules/TelegramUI/TelegramUI/UniversalVideoGalleryItem.swift @@ -154,6 +154,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { private let footerContentNode: ChatItemGalleryFooterContentNode private var videoNode: UniversalVideoNode? + private var videoFramePreview: MediaPlayerFramePreview? private var pictureInPictureNode: UniversalVideoGalleryItemPictureInPictureNode? private let statusButtonNode: HighlightableButtonNode private let statusNode: RadialStatusNode @@ -178,6 +179,10 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { private var fetchStatus: MediaResourceStatus? private var fetchControls: FetchControls? + private var scrubbingFrame = Promise(nil) + private var scrubbingFrames = false + private var scrubbingFrameDisposable: Disposable? + var playbackCompleted: (() -> Void)? init(context: AccountContext, presentationData: PresentationData, performAction: @escaping (GalleryControllerInteractionTapAction) -> Void, openActionOptions: @escaping (GalleryControllerInteractionTapAction) -> Void) { @@ -203,6 +208,23 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { self?.videoNode?.seek(timecode) } + self.scrubberView.updateScrubbing = { [weak self] timecode in + guard let strongSelf = self, let videoFramePreview = strongSelf.videoFramePreview else { + return + } + if let timecode = timecode { + if !strongSelf.scrubbingFrames { + strongSelf.scrubbingFrames = true + strongSelf.scrubbingFrame.set(videoFramePreview.generatedFrames) + } + videoFramePreview.generateFrame(at: timecode) + } else { + strongSelf.scrubbingFrame.set(.single(nil)) + videoFramePreview.cancelPendingFrames() + strongSelf.scrubbingFrames = false + } + } + self.statusButtonNode.addSubnode(self.statusNode) self.statusButtonNode.addTarget(self, action: #selector(statusButtonPressed), forControlEvents: .touchUpInside) @@ -255,10 +277,19 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { break } } + + self.scrubbingFrameDisposable = (self.scrubbingFrame.get() + |> deliverOnMainQueue).start(next: { [weak self] image in + guard let strongSelf = self else { + return + } + strongSelf.footerContentNode.setFramePreviewImage(image: image) + }) } deinit { self.statusDisposable.dispose() + self.scrubbingFrameDisposable?.dispose() } override func ready() -> Signal { @@ -304,6 +335,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { var isAnimated = false if let content = item.content as? NativeVideoContent { isAnimated = content.fileReference.media.isAnimated + self.videoFramePreview = MediaPlayerFramePreview(postbox: item.context.account.postbox, fileReference: content.fileReference) } else if let _ = item.content as? SystemVideoContent { self._title.set(.single(item.presentationData.strings.Message_Video)) } else if let content = item.content as? WebEmbedVideoContent, case .iframe = webEmbedType(content: content.webpageContent) { diff --git a/submodules/ffmpeg/FFMpeg/FFMpegAVFrame.h b/submodules/ffmpeg/FFMpeg/FFMpegAVFrame.h index b5960710b5..e1145e3cba 100644 --- a/submodules/ffmpeg/FFMpeg/FFMpegAVFrame.h +++ b/submodules/ffmpeg/FFMpeg/FFMpegAVFrame.h @@ -2,6 +2,11 @@ NS_ASSUME_NONNULL_BEGIN +typedef NS_ENUM(NSUInteger, FFMpegAVFrameColorRange) { + FFMpegAVFrameColorRangeRestricted, + FFMpegAVFrameColorRangeFull +}; + @interface FFMpegAVFrame : NSObject @property (nonatomic, readonly) int32_t width; @@ -9,6 +14,7 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, readonly) uint8_t **data; @property (nonatomic, readonly) int *lineSize; @property (nonatomic, readonly) int64_t pts; +@property (nonatomic, readonly) FFMpegAVFrameColorRange colorRange; - (instancetype)init; diff --git a/submodules/ffmpeg/FFMpeg/FFMpegAVFrame.m b/submodules/ffmpeg/FFMpeg/FFMpegAVFrame.m index 9bd1ec8a10..fd1fcb203a 100644 --- a/submodules/ffmpeg/FFMpeg/FFMpegAVFrame.m +++ b/submodules/ffmpeg/FFMpeg/FFMpegAVFrame.m @@ -44,6 +44,16 @@ return _impl->pts; } +- (FFMpegAVFrameColorRange)colorRange { + switch (_impl->color_range) { + case AVCOL_RANGE_MPEG: + case AVCOL_RANGE_UNSPECIFIED: + return FFMpegAVFrameColorRangeRestricted; + default: + return FFMpegAVFrameColorRangeFull; + } +} + - (void *)impl { return _impl; } diff --git a/submodules/ffmpeg/FFMpeg/build-ffmpeg.sh b/submodules/ffmpeg/FFMpeg/build-ffmpeg.sh index 5f962d8080..e7c155dc02 100755 --- a/submodules/ffmpeg/FFMpeg/build-ffmpeg.sh +++ b/submodules/ffmpeg/FFMpeg/build-ffmpeg.sh @@ -48,7 +48,6 @@ set -e CONFIGURE_FLAGS="--enable-cross-compile --disable-programs \ --disable-armv5te --disable-armv6 --disable-armv6t2 \ --disable-doc --enable-pic --disable-all --disable-everything \ - --disable-videotoolbox \ --enable-avcodec \ --enable-swresample \ --enable-avformat \ @@ -56,13 +55,16 @@ CONFIGURE_FLAGS="--enable-cross-compile --disable-programs \ --enable-libopus \ --enable-audiotoolbox \ --enable-bsf=aac_adtstoasc \ - --enable-decoder=h264,libopus,mp3_at,aac_at,flac,alac_at,pcm_s16le,pcm_s24le,gsm_ms_at \ + --enable-decoder=h264,hevc,libopus,mp3_at,aac_at,flac,alac_at,pcm_s16le,pcm_s24le,gsm_ms_at \ --enable-demuxer=aac,mov,m4v,mp3,ogg,libopus,flac,wav,aiff,matroska \ --enable-parser=aac,h264,mp3,libopus \ --enable-protocol=file \ --enable-muxer=mp4 \ " + +#--enable-hwaccel=h264_videotoolbox,hevc_videotoolbox \ + if [ "$1" = "debug" ]; then CONFIGURE_FLAGS="$CONFIGURE_FLAGS --disable-optimizations --disable-stripping" diff --git a/submodules/ffmpeg/FFMpeg_Xcode.xcodeproj/project.pbxproj b/submodules/ffmpeg/FFMpeg_Xcode.xcodeproj/project.pbxproj index 0a669cd68f..8962e29030 100644 --- a/submodules/ffmpeg/FFMpeg_Xcode.xcodeproj/project.pbxproj +++ b/submodules/ffmpeg/FFMpeg_Xcode.xcodeproj/project.pbxproj @@ -36,6 +36,7 @@ D04555D721BF8B2F007A6DD9 /* AudioToolbox.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D04555D621BF8B2F007A6DD9 /* AudioToolbox.framework */; }; D04555D921BF8B4E007A6DD9 /* libiconv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = D04555D821BF8B4E007A6DD9 /* libiconv.tbd */; }; D04555DB21BF8B77007A6DD9 /* libopus.a in Frameworks */ = {isa = PBXBuildFile; fileRef = D04555DA21BF8B77007A6DD9 /* libopus.a */; }; + D0E8B10E22D8E97B00C82570 /* VideoToolbox.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D0E8B10D22D8E97B00C82570 /* VideoToolbox.framework */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -71,6 +72,7 @@ D04555D821BF8B4E007A6DD9 /* libiconv.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libiconv.tbd; path = usr/lib/libiconv.tbd; sourceTree = SDKROOT; }; D04555DA21BF8B77007A6DD9 /* libopus.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libopus.a; path = opus/lib/libopus.a; sourceTree = ""; }; D0CAD6A621C049D9001E3055 /* ffmpeg-4.1 */ = {isa = PBXFileReference; lastKnownFileType = folder; path = "ffmpeg-4.1"; sourceTree = ""; }; + D0E8B10D22D8E97B00C82570 /* VideoToolbox.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = VideoToolbox.framework; path = System/Library/Frameworks/VideoToolbox.framework; sourceTree = SDKROOT; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -78,6 +80,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + D0E8B10E22D8E97B00C82570 /* VideoToolbox.framework in Frameworks */, D000CABF21F76B1B0011B15D /* libbz2.tbd in Frameworks */, D04555DB21BF8B77007A6DD9 /* libopus.a in Frameworks */, D04555D921BF8B4E007A6DD9 /* libiconv.tbd in Frameworks */, @@ -142,6 +145,7 @@ D04554C921BF1119007A6DD9 /* Frameworks */ = { isa = PBXGroup; children = ( + D0E8B10D22D8E97B00C82570 /* VideoToolbox.framework */, D000CABE21F76B1B0011B15D /* libbz2.tbd */, D04555DA21BF8B77007A6DD9 /* libopus.a */, D04555D821BF8B4E007A6DD9 /* libiconv.tbd */,