Play, record và merge video trong iOS

Introduction

Trong bài viết này, chúng ta sẽ tìm hiểu về các thao tác cơ bản với video trong iOS sử dụng framework AVFoundation. Bao gồm play, record và thậm chí một chút edit video. AVFoundation là một framework cơ bản, là một phần của macOS từ OS X Lion (10.7) và iOS 4 từ năm 2010.

Đến nay AVFoundation ngày càng được hoàn thiện với hơn 100 class hỗ trợ việc xử lý audio và video. Bài viết này sẽ tập trung vào việc sử dụng các class của framework này để:

  • Select và play video từ media library.
  • Record và save video vào media library.
  • Merge hai video thành một video hoàn chỉnh, có thể thêm audio soundtrack.

Để thực hành tutorial này, chúng ta sẽ cần một device thật, chứ không thể dùng simulator. Đơn giản là vì chúng ta không thể dùng simulator để mở camera và record video. Và để run app trên device thật, chúng ta lại cần một tài khoản developer Apple, tài khoản free là đủ.

Getting Started

Mở Xcode lên, tạo project Swift mới với tên tùy ý.

Trong Main.storyboard, embed default view controller vào một UINavigationController.

Tạo mới 3 UIButton với action show segue tới 3 view controller mới tương ứng với:

  • Select and play video (PlayVideoViewController.swift).
  • Record and save video (RecordVideoViewController.swift).
  • Merge video (MergeVideoViewController.swift).

Build và run app trên device thật, ta được giao diện đơn giản như sau:

Select and play video

Button Select and play video có action khi tap vào sẽ segue đến PlayVideoViewController. Trong phần này, chúng ta sẽ viết code để select và play một video từ media library.

Bắt đầu bằng việc mở file PlayVideoViewController.swift và thêm các import sau vào đầu file:

import AVKit
import MobileCoreServices

Import AVKit cho phép chúng ta sử dụng class AVPlayer hỗ trợ việc select và play video. Còn MobileCoreServices để sử dụng các constant được define trước như kUTTypeMovie - constant để chỉ định media type dạng video khi select từ media library.

Tiếp theo, thêm các extension sau và conform hai protocol UIImagePickerControllerDelegateUINavigationControllerDelegate. Chúng ta sẽ sử dụng UIImagePickerViewController mặc định của iOS để browse video từ media library. Mặc dù tên class này là ImagePicker nhưng nó cũng cho phép pick cả video. Sau khi select xong, class này sẽ call lại các method của delegate protocol. Từ đó chúng ta có thể handle video hoặc ảnh vừa được chọn một cách tùy ý.

// MARK: - UIImagePickerControllerDelegate methods

extension PlayVideoViewController: UIImagePickerControllerDelegate {
    
}

// MARK: - UINavigationControllerDelegate methods

extension PlayVideoViewController: UINavigationControllerDelegate {
    
}

Từ Main.storyboard, thêm mới một button Select video vào PlayVideoViewController và tạo @IBAction func selectVideoButtonTapped(_ sender: Any) tương ứng cho nó ở view controller.

Trong function này, thêm đoạn code sau:

        // 1.
        guard UIImagePickerController.isSourceTypeAvailable(.savedPhotosAlbum) else {
            print("Media library is not available.")
            return
        }
        // 2.
        let imagePicker = UIImagePickerController()
        imagePicker.sourceType = .savedPhotosAlbum
        imagePicker.mediaTypes = [kUTTypeMovie as String]
        imagePicker.allowsEditing = true
        imagePicker.delegate = self
        // 3.
        present(imagePicker, animated: true, completion: nil)

Khi tap vào button Select video, chúng ta sẽ present màn hình browse đến media library. Cụ thể:

  1. Check xem source type .savedPhotosAlbum (media library) có available trên device hay không. Ngoài media library còn có source photo từ camera của device.
  2. Nếu source type đó available, tạo một object của class UIImagePickerController và set các property cần thiết như delegate, sourceType, mediaTypes...
  3. Cuối cùng, present modally image picker.

Build và run app, tap button Select and play video ở màn hình đầu tiên rồi tap button Select video, chúng ta sẽ thấy giao diện select video từ media library như sau:

Bởi vì ở trên chúng ta đã set property mediaTypes bằng kUTTypeMovie nên khi open media library đã tự động filter chỉ hiển thị các video. Khi chọn một video, hệ thống sẽ tự động hiển thị một màn hình video detail với các button canncel, play và choose. Tap button play sẽ play video. Tuy nhiên nếu tap button choose thì sẽ quay lại màn hình PlayVideoViewController mà không có bất kỳ action xảy ra. Sở dĩ như vậy vì chúng ta chưa implement bất kỳ method delegate nào để handle video được chọn từ picker.

Quay trở lại Xcode, trong extension UIImagePickerControllerDelegate, implement thêm method delegate sau:

    func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
        // 1.
        guard let mediaType = info[.mediaType] as? String,
              mediaType == (kUTTypeMovie as String),
              let url = info[.mediaURL] as? URL else {
            return
        }
        // 2.
        dismiss(animated: true) {
            // 3.
            let player = AVPlayer(url: url)
            let playerViewController = AVPlayerViewController()
            playerViewController.player = player
            self.present(playerViewController, animated: true, completion: nil)
        }
    }
  1. Check media type của media vừa được select và unwrap lấy giá trị url của media đó.
  2. Dismiss image picker.
  3. Trong completion block, tạo mới object AVPlayerViewControllerAVPlayer để play video rồi present lên.

Build và run project, chọn một video, chúng ta sẽ play được video đó:

Record and save video

Ở phần này, chúng ta sẽ implement phần record video và save nó xuống media library.

Mở file RecordVideoViewController.swift, cũng cần import MobileCoreServices.

import MobileCoreServices

Implement tương tự phần play video, chúng ta thêm các extension.

// MARK: - UINavigationControllerDelegate methods

extension RecordVideoViewController: UIImagePickerControllerDelegate {
    
}

// MARK: - UINavigationControllerDelegate methods

extension RecordVideoViewController: UINavigationControllerDelegate {
    
}

Và thêm button Record video và funtion handle button tương ứng:

    @IBAction private func recordVideoButtonTapped(_ sender: Any) {
        guard UIImagePickerController.isSourceTypeAvailable(.camera) else {
            print("Camera is not available.")
            return
        }

        let imagePicker = UIImagePickerController()
        imagePicker.sourceType = .camera
        imagePicker.mediaTypes = [kUTTypeMovie as String]
        imagePicker.allowsEditing = true
        imagePicker.delegate = self

        present(imagePicker, animated: true, completion: nil)
    }

Ở trên chúng ta đã request mở camera của device để quay video nên để tránh crash app khi tap vào button Record video, chúng ta cần thêm các key, value sau vào Info.plist để mô tả việc xin quyền truy cập camera và microphone.

	<key>NSMicrophoneUsageDescription</key>
	<string>Allow microphone access description</string>
	<key>NSCameraUsageDescription</key>
	<string>Allow camera access description</string>

Build và run app xem chúng ta có gì:

Chuyển đến màn hình RecordVideoViewController và tap vào button Record video. Thay vì hiển thị màn hình Photo Gallery, giao diện camera hiện lên. Lúc này system dialog hiện lên hỏi xin user quyền cho phép sử dụng camera và microphone, tap OK. Sau khi record video có thể retake video hoặc tap button Use Video để select video vừa quay. Giao diện camera sẽ dismiss nhưng không có action gì xảy ra cả, cũng giống như trên, vì chúng ta chưa implement method delegate để handle.

Trong extension UIImagePickerControllerDelegate thêm method sau:

    func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
        dismiss(animated: true, completion: nil)
        guard let mediaType = info[UIImagePickerController.InfoKey.mediaType] as? String,
              mediaType == (kUTTypeMovie as String),
              let url = info[UIImagePickerController.InfoKey.mediaURL] as? URL,
              UIVideoAtPathIsCompatibleWithSavedPhotosAlbum(url.path) else {
            return
        }
        
        UISaveVideoAtPathToSavedPhotosAlbum(url.path, self, #selector(video(_:didFinishSavingWithError:contextInfo:)), nil)
    }

Cũng giống phần trước, method này trả về URL trỏ đến địa chỉ của video vừa được record, việc của chúng ta chỉ là verify các thông tin hợp lệ và check xem có thể save video đó xuống media library được hay không.

UISaveVideoAtPathToSavedPhotosAlbum là funtion cung cấp bởi iOS SDK UIKit để save video xuống Photos Album. Chúng ta cần truyền vào function này các param như videoPath, target, completionSelector và contextInfo.

Tiếp theo, chúng ta implement target action completionSelector sau khi save video complete.

    @objc private func video(_ videoPath: String, didFinishSavingWithError error: Error?, contextInfo info: AnyObject) {
        let title = error == nil ? "Success" : "Error"
        let message = error == nil ? "Video was saved" : "Video failed to save"
        
        let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert)
        alertController.addAction(UIAlertAction(title: "OK", style: .cancel, handler: nil))
        present(alertController, animated: true, completion: nil)
    }

Một việc nữa cần phải làm, đó lại thêm key NSPhotoLibraryAddUsageDescription vào Info.plist. Build và run app. Nếu save thành công sẽ hiện ra alert Video was saved.

Merge video

Việc đầu tiên để merge video đó là phải select hai video từ media library. Sử dụng lại code select video từ phần Select and play video viết thành method return Bool như sau:

    private func loadVideo() -> Bool {
        guard UIImagePickerController.isSourceTypeAvailable(.savedPhotosAlbum) else {
            print("Media library is not available.")
            return false
        }
        let imagePicker = UIImagePickerController()
        imagePicker.sourceType = .savedPhotosAlbum
        imagePicker.mediaTypes = [kUTTypeMovie as String]
        imagePicker.allowsEditing = true
        imagePicker.delegate = self
        present(imagePicker, animated: true, completion: nil)
        return true
    }

Tiếp theo, ở Main.storyboard, thêm 4 button vào MergeVideoViewController.

Và 4 action method tương ứng. Sử dụng hai flag Bool loadingFirstVideoloadingSecondVideo để phân biệt việc load video thứ nhất và thứ hai. Và các biến lưu video, audio asset sau khi select.

    private var loadingFirstVideo = false
    private var loadingSecondVideo = false
    private var firstAsset: AVAsset?
    private var secondAsset: AVAsset?
    private var audioAsset: AVAsset?
    @IBAction private func loadFirstVideoButtonTapped(_ sender: Any) {
        if loadVideo() {
            loadingFirstVideo = true
        }
    }
    
    @IBAction private func loadSecondVideoButtonTapped(_ sender: Any) {
        if loadVideo() {
            loadingSecondVideo = false
        }
    }
    
    @IBAction private func loadAudioButtonTapped(_ sender: Any) {
        
    }
    
    @IBAction private func mergeVideoButtonTapped(_ sender: Any) {
        
    }

Method handle select video.

    func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
        dismiss(animated: true, completion: nil)
        
        guard let mediaType = info[.mediaType] as? String,
              mediaType == (kUTTypeMovie as String),
              let url = info[.mediaURL] as? URL else {
            return
        }
        
        let asset = AVAsset(url: url)
        var message = ""
        if loadingFirstVideo {
            message = "First loaded"
            firstAsset = asset
            loadingFirstVideo = false
        } else if loadingSecondVideo {
            message = "Video two loaded"
            secondAsset = asset
            loadingFirstVideo = false
        } else {
            message = "No video loaded"
        }
        let alertController = UIAlertController(title: "Asset Loaded", message: message, preferredStyle: .alert)
        alertController.addAction(UIAlertAction(title: "OK", style: .cancel, handler: nil))
        present(alertController, animated: true, completion: nil)
    }

Để load audio, import thêm MediaPlayer, trong method loadAudioButtonTapped(_ sender: Any), implement đoạn code sau:

        let mediaPickerController = MPMediaPickerController(mediaTypes: .any)
        mediaPickerController.delegate = self
        mediaPickerController.prompt = "Select Audio"
        present(mediaPickerController, animated: true, completion: nil)

Để xin quyền truy cập kho audio của device, chúng ta cũng cần thêm key, value cho NSAppleMusicUsageDescription.

	<key>NSAppleMusicUsageDescription</key>
	<string>Allow Apple Music access description</string>

MergeVideoViewController cũng cần comform delegate MPMediaPickerControllerDelegate.

// MARK: - MPMediaPickerControllerDelegate methods

extension MergeVideoViewController: MPMediaPickerControllerDelegate {
    
}

Khi tap vào button Load audio, giao diện select audio của Apple Music hiện lên.

Trong extension MPMediaPickerControllerDelegate, override method để handle audio sau khi pick.

    func mediaPicker(_ mediaPicker: MPMediaPickerController, didPickMediaItems mediaItemCollection: MPMediaItemCollection) {
        dismiss(animated: true) {
            let selectedSongs = mediaItemCollection.items
            guard let song = selectedSongs.first else {
                return
            }
            
            let url = song.value(forProperty: MPMediaItemPropertyAssetURL) as? URL
            if let assetURL = url {
                self.audioAsset = AVAsset(url: assetURL)
            }
            let title = url == nil ? "Asset Not Available" : "Asset Loaded"
            let message = url == nil ? "Audio Not Loaded" : "Audio Loaded"
            
            let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
            alert.addAction(UIAlertAction(title: "OK", style: .cancel, handler:nil))
            self.present(alert, animated: true, completion: nil)
        }
    }
    
    func mediaPickerDidCancel(_ mediaPicker: MPMediaPickerController) {
        dismiss(animated: true, completion: nil)
    }

Trong method trên, chúng ta lấy ra audio đầu tiên chọn được, check URL và lưu vào biến audioAsset và hiển thị message alert tương ứng khi load thành công hoặc fail.

Export and merge

Để export video đã merge, import Photos và thêm method sau vào MergeVideoViewController.swift:

    private func exportDidFinish(_ session: AVAssetExportSession) {
        // Cleanup assets
        firstAsset = nil
        secondAsset = nil
        audioAsset = nil
        
        guard session.status == .completed,
              let outputURL = session.outputURL else {
            return
        }
        
        // Closure to save video to Media Library
        let saveVideoToPhotos = {
            PHPhotoLibrary.shared().performChanges({
                PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: outputURL)
            }) { saved, error in
                let success = saved && error == nil
                let title = success ? "Success" : "Error"
                let message = success ? "Video saved" : "Failed to save video"
                
                let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert)
                alertController.addAction(UIAlertAction(title: "OK", style: .cancel, handler: nil))
                self.present(alertController, animated: true, completion: nil)
            }
        }
        
        // Ensure permission to access Media Library
        if PHPhotoLibrary.authorizationStatus() != .authorized {
            PHPhotoLibrary.requestAuthorization { status in
                if status == .authorized {
                    saveVideoToPhotos()
                }
            }
        } else {
            saveVideoToPhotos()
        }
    }

Hoàn thiện method mergeVideoButtonTapped(_ sender: Any):

    @IBAction private func mergeVideoButtonTapped(_ sender: Any) {
        guard let firstAsset = firstAsset,
              let secondAsset = secondAsset else {
            return
        }
        
        // 1 - Create AVMutableComposition object. This object will hold your AVMutableCompositionTrack instances.
        let mixComposition = AVMutableComposition()
        
        // 2 - Create two video tracks
        guard let firstTrack = mixComposition.addMutableTrack(withMediaType: .video,
                                                              preferredTrackID: Int32(kCMPersistentTrackID_Invalid)) else {
            return
        }
        
        do {
            try firstTrack.insertTimeRange(CMTimeRangeMake(start: .zero, duration: firstAsset.duration),
                                           of: firstAsset.tracks(withMediaType: .video)[0],
                                           at: .zero)
        } catch {
            print("Failed to load first track")
            return
        }
        
        guard let secondTrack = mixComposition.addMutableTrack(withMediaType: .video,
                                                             preferredTrackID: Int32(kCMPersistentTrackID_Invalid)) else {
            return
        }
        
        do {
            try secondTrack.insertTimeRange(CMTimeRangeMake(start: .zero, duration: secondAsset.duration),
                                            of: secondAsset.tracks(withMediaType: .video)[0],
                                            at: firstAsset.duration)
        } catch {
            print("Failed to load second track")
            return
        }
        
        // 3 - Audio track
        if let loadedAudioAsset = audioAsset {
            let audioTrack = mixComposition.addMutableTrack(withMediaType: .audio, preferredTrackID: 0)
            do {
                try audioTrack?.insertTimeRange(CMTimeRangeMake(start: .zero,
                                                                duration: CMTimeAdd(firstAsset.duration,
                                                                                    secondAsset.duration)),
                                                of: loadedAudioAsset.tracks(withMediaType: AVMediaType.audio)[0] ,
                                                at: .zero)
            } catch {
                print("Failed to load Audio track")
            }
        }
        
        // 4 - Get path
        guard let documentDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else {
            return
        }
        let dateFormatter = DateFormatter()
        dateFormatter.dateStyle = .long
        dateFormatter.timeStyle = .short
        let date = dateFormatter.string(from: Date())
        let url = documentDirectory.appendingPathComponent("mergeVideo-\(date).mov")
        
        // 5 - Create Exporter
        guard let exporter = AVAssetExportSession(asset: mixComposition, presetName: AVAssetExportPresetHighestQuality) else {
            return
        }
        exporter.outputURL = url
        exporter.outputFileType = AVFileType.mov
        exporter.shouldOptimizeForNetworkUse = true
        
        // 6 - Perform the Export
        exporter.exportAsynchronously() {
            DispatchQueue.main.async {
                self.exportDidFinish(exporter)
            }
        }
    }

Run app và select hai video cùng với một audio tùy ý, tap button Merge and save, alert message Video saved hiện lên. Có thể dùng ngay chức năng play video ở trên để play video vừa được merge.

Tutorial này còn một số vấn đề về video orientation và xử lý các khoảng đen còn thừa xong video. Các bạn có thể tìm hiểu thêm ở bài viết gốc:


All Rights Reserved