Skip to content

Commit

Permalink
Buffer capturer test (#355)
Browse files Browse the repository at this point in the history
  • Loading branch information
hiroshihorie authored Mar 10, 2024
1 parent 39935bb commit fceb456
Show file tree
Hide file tree
Showing 4 changed files with 267 additions and 3 deletions.
91 changes: 91 additions & 0 deletions Tests/LiveKitTests/BufferCapturerTest.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
7 changes: 5 additions & 2 deletions Tests/LiveKitTests/Support/SetUpRooms.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
148 changes: 148 additions & 0 deletions Tests/LiveKitTests/Support/TrackHelper.swift
Original file line number Diff line number Diff line change
@@ -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<Void, any Error>) {
// 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: ", ")))")
}
}
24 changes: 23 additions & 1 deletion Tests/LiveKitTests/Support/Xcode14.2Backport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down

0 comments on commit fceb456

Please sign in to comment.