diff --git a/Tests/LiveKitTests/BufferCapturerTest.swift b/Tests/LiveKitTests/BufferCapturerTest.swift new file mode 100644 index 000000000..08d0fd586 --- /dev/null +++ b/Tests/LiveKitTests/BufferCapturerTest.swift @@ -0,0 +1,91 @@ +/* + * Copyright 2024 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import AVFoundation +@testable import LiveKit +import XCTest + +class BufferCapturerTest: XCTestCase { + func testPublishBufferTrack() async throws { + try await with2Rooms { room1, room2 in + + let targetDimensions: Dimensions = .h1080_169 + + let bufferTrack = LocalVideoTrack.createBufferTrack( + options: BufferCaptureOptions(dimensions: targetDimensions) + ) + + let bufferCapturer = bufferTrack.capturer as! BufferCapturer + + let captureTask = try await self.createSampleVideoTrack { buffer in + bufferCapturer.capture(buffer) + } + + try await room1.localParticipant.publish(videoTrack: bufferTrack) + + guard let publisherIdentity = room1.localParticipant.identity else { + XCTFail("Publisher's identity is nil") + return + } + + // Get publisher's participant + guard let remoteParticipant = room2.remoteParticipants[publisherIdentity] else { + XCTFail("Failed to lookup Publisher (RemoteParticipant)") + return + } + + // Set up expectation... + let didSubscribeToRemoteVideoTrack = self.expectation(description: "Did subscribe to remote video track") + didSubscribeToRemoteVideoTrack.assertForOverFulfill = false + + var remoteVideoTrack: RemoteVideoTrack? + + // Start watching RemoteParticipant for audio track... + let watchParticipant = remoteParticipant.objectWillChange.sink { _ in + if let track = remoteParticipant.videoTracks.first?.track as? RemoteVideoTrack, remoteVideoTrack == nil { + remoteVideoTrack = track + didSubscribeToRemoteVideoTrack.fulfill() + } + } + + // Wait for track... + print("Waiting for first video track...") + await self.fulfillment(of: [didSubscribeToRemoteVideoTrack], timeout: 30) + + guard let remoteVideoTrack else { + XCTFail("RemoteVideoTrack is nil") + return + } + + // Received RemoteAudioTrack... + print("remoteVideoTrack: \(String(describing: remoteVideoTrack))") + + let videoTrackWatcher = VideoTrackWatcher(id: "watcher01") + remoteVideoTrack.add(videoRenderer: videoTrackWatcher) + remoteVideoTrack.add(delegate: videoTrackWatcher) + + print("Waiting for target dimensions: \(targetDimensions)") + let expectTargetDimensions = videoTrackWatcher.expect(dimensions: targetDimensions) + await self.fulfillment(of: [expectTargetDimensions], timeout: 30) + print("Did render target dimensions: \(targetDimensions)") + + // Wait for video to complete... + try await captureTask.value + // Clean up + watchParticipant.cancel() + } + } +} diff --git a/Tests/LiveKitTests/Support/SetUpRooms.swift b/Tests/LiveKitTests/Support/SetUpRooms.swift index 12d12ab37..12c34967e 100644 --- a/Tests/LiveKitTests/Support/SetUpRooms.swift +++ b/Tests/LiveKitTests/Support/SetUpRooms.swift @@ -38,8 +38,11 @@ extension XCTestCase { // Set up 2 Rooms func with2Rooms(_ block: @escaping (Room, Room) async throws -> Void) async throws { - let room1 = Room() - let room2 = Room() + // Turn on stats + let roomOptions = RoomOptions(reportRemoteTrackStatistics: true) + + let room1 = Room(roomOptions: roomOptions) + let room2 = Room(roomOptions: roomOptions) let url = testUrl() diff --git a/Tests/LiveKitTests/Support/TrackHelper.swift b/Tests/LiveKitTests/Support/TrackHelper.swift new file mode 100644 index 000000000..ff6f46032 --- /dev/null +++ b/Tests/LiveKitTests/Support/TrackHelper.swift @@ -0,0 +1,148 @@ +/* + * Copyright 2024 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import AVFoundation +@testable import LiveKit +import XCTest + +extension XCTestCase { + // Creates a LocalVideoTrack with BufferCapturer, generates frames for approx 30 seconds + func createSampleVideoTrack(targetFps: Int = 30, _ onCapture: @escaping (CMSampleBuffer) -> Void) async throws -> (Task) { + // Sample video + let url = URL(string: "https://storage.unxpected.co.jp/public/sample-videos/ocean-1080p.mp4")! + + print("Downloading sample video from \(url)...") + let (downloadedLocalUrl, _) = try await URLSession.shared.downloadBackport(from: url) + + // Move the file to a new temporary location with a more descriptive name, if desired + let tempLocalUrl = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString).appendingPathExtension("mp4") + try FileManager.default.moveItem(at: downloadedLocalUrl, to: tempLocalUrl) + + print("Opening \(tempLocalUrl) with asset reader...") + let asset = AVAsset(url: tempLocalUrl) + let assetReader = try AVAssetReader(asset: asset) + + guard let track = asset.tracks(withMediaType: .video).first else { + XCTFail("No video track found in sample video file") + fatalError() + } + + let outputSettings: [String: Any] = [ + kCVPixelBufferPixelFormatTypeKey as String: Int(kCVPixelFormatType_32BGRA), + ] + + let trackOutput = AVAssetReaderTrackOutput(track: track, outputSettings: outputSettings) + assetReader.add(trackOutput) + + // Start reading... + guard assetReader.startReading() else { + XCTFail("Could not start reading the asset.") + fatalError() + } + + let readBufferTask = Task.detached { + let frameDuration = UInt64(1_000_000_000 / targetFps) + while !Task.isCancelled, assetReader.status == .reading, let sampleBuffer = trackOutput.copyNextSampleBuffer() { + onCapture(sampleBuffer) + // Sleep for the frame duration to regulate to ~30 fps + try await Task.sleep(nanoseconds: frameDuration) + } + } + + return readBufferTask + } +} + +class VideoTrackWatcher: TrackDelegate, VideoRenderer { + // MARK: - Public + + typealias OnDidRenderFirstFrame = (_ sid: String) -> Void + public var didRenderFirstFrame: Bool { _state.didRenderFirstFrame } + + private struct State { + var didRenderFirstFrame: Bool = false + var expectationsForDimensions: [Dimensions: XCTestExpectation] = [:] + } + + public let id: String + private let _state = StateSync(State()) + private let onDidRenderFirstFrame: OnDidRenderFirstFrame? + + init(id: String, onDidRenderFirstFrame: OnDidRenderFirstFrame? = nil) { + self.id = id + self.onDidRenderFirstFrame = onDidRenderFirstFrame + } + + public func reset() { + _state.mutate { $0.didRenderFirstFrame = false } + } + + public func expect(dimensions: Dimensions) -> XCTestExpectation { + let expectation = XCTestExpectation(description: "Did render dimension \(dimensions)") + expectation.assertForOverFulfill = false + + return _state.mutate { + $0.expectationsForDimensions[dimensions] = expectation + return expectation + } + } + + // MARK: - VideoRenderer + + var isAdaptiveStreamEnabled: Bool { true } + + var adaptiveStreamSize: CGSize { .init(width: 1920, height: 1080) } + + func set(size: CGSize) { + print("\(type(of: self)) set(size: \(size))") + } + + func render(frame: LiveKit.VideoFrame) { + _state.mutate { + if !$0.didRenderFirstFrame { + $0.didRenderFirstFrame = true + onDidRenderFirstFrame?(id) + } + + for (key, value) in $0.expectationsForDimensions { + if frame.dimensions.area >= key.area { + value.fulfill() + } + } + } + } + + // MARK: - TrackDelegate + + func track(_: Track, didUpdateStatistics statistics: TrackStatistics, simulcastStatistics _: [VideoCodec: TrackStatistics]) { + guard let stream = statistics.inboundRtpStream.first else { return } + var segments: [String] = [] + + if let codec = statistics.codec.first(where: { $0.id == stream.codecId }), let mimeType = codec.mimeType { + segments.append("codec: \(mimeType)") + } + + if let width = stream.frameWidth, let height = stream.frameHeight { + segments.append("dimensions: \(width)x\(height)") + } + + if let fps = stream.framesPerSecond { + segments.append("fps: \(fps)") + } + + print("\(type(of: self)) didUpdateStatistics (\(segments.joined(separator: ", ")))") + } +} diff --git a/Tests/LiveKitTests/Support/Xcode14.2Backport.swift b/Tests/LiveKitTests/Support/Xcode14.2Backport.swift index 64145a575..ff15a4baa 100644 --- a/Tests/LiveKitTests/Support/Xcode14.2Backport.swift +++ b/Tests/LiveKitTests/Support/Xcode14.2Backport.swift @@ -17,7 +17,29 @@ import Foundation import XCTest -/// Support for Xcode 14.2 +// Support iOS 13 +public extension URLSession { + func downloadBackport(from url: URL) async throws -> (URL, URLResponse) { + if #available(iOS 15.0, macOS 12.0, *) { + return try await download(from: url) + } else { + return try await withCheckedThrowingContinuation { continuation in + let task = downloadTask(with: url) { url, response, error in + if let url, let response { + continuation.resume(returning: (url, response)) + } else if let error { + continuation.resume(throwing: error) + } else { + fatalError("Unknown state") + } + } + task.resume() + } + } + } +} + +// Support for Xcode 14.2 #if !compiler(>=5.8) extension XCTestCase { func fulfillment(of expectations: [XCTestExpectation], timeout: TimeInterval, enforceOrder: Bool = false) async {