3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Swiftで弾き語り動画に好きなタイミングで歌詞を入れた伝説回

Last updated at Posted at 2021-03-17

畑田です。
AVFoundationを使って撮影した動画に背景画像やタップしている時間だけ歌詞を入れる機能を実装しました。(すごい!)
AVFoundationにおける動画編集は正確でわかりやすい情報が少ないので、登場するクラスの解説を交えて実装を記録しておきます。

AVComposition

AVCompositionは複数のソースからなるデータを重ね合わせるためのクラスであり、AVAssetのサブクラスです。
AVCompositionは音声や映像といったそれぞれのメディアを意味するトラック(AVCompositionTrackというAVAssetTrackを継承したクラスで表される)という単位の集合です。やっぱりAVAssetの友達。
またそれぞれのトラックはsegment(AVCompositionTrackSegment)によって構成されており、これはURL、track identifier、time mappingなどでコンテナに保存されている各メディアデータを表しています。
AVCompositionAVAssetのサブクラスであることからなんとなくわかるかもしれませんが、AVPlayerなどに渡して再生することができます。
また、ファイルで表せる全てのAVデータはコンテナ形式によらず、合成することができます。
time mappingはメディアの長さを管理しており、元データと保存先(合成先)データのタイムレンジが等しければそのまま保存し、異なれば等倍で伸ばして保存するなどするということです。
こういったtrackやsegmentには簡単にアクセスできて書き出すこともできるし、AVMutableCompositionTrackを用いればAVMutableCompositionを新しくインスタンス化することで、compositionを再構築、新規作成することもできます。
AVMutableCompositionTrackAVMutableCompositionを用いれば、メディアの挿入や削除、スケーリングなどの操作を高レイヤーにおいて行うことができます。
特に、動画編集、compositionの新規作成については以下の図表を参照してください。

name class description
Composition AVMutableComposition 一つの映像作品 / プロジェクト
Track AVMutableCompositionTrack Compositionを構成する映像/音声トラック
Asset AVAsset Compositionの各トラックにのせるトラックを持つ素材

少しソースコード書いてみます。

// create an empty composition
let mutableComposition = AVMutableComposition()

// add an empty video track to the composition
guard let compositionVideoTrack = mutableComposition.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid) else {
    print("failed to add video track")
    return
}

// add an empty audio track to the composition
guard let compositionAudioTrack = mutableComposition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid) else {
    print("failed to add audio track")
    return
}

// add a video track of asset to the composition video track
let videoAsset = AVURLAsset(url: someURL)
guard let _ = videoAsset.tracks(withMediaType: .video).first else { return }
let sourceVideoTrack = videoAsset.tracks(withMediaType: .video)[0]
do {
    try compositionVideoTrack.insertTimeRange(sourceVideoTrack.timeRange, of: sourceVideoTrack, at: .zero)
} catch {
    print(error)
}

// add a audio track of asset to the composition audio track
let audioAsset = AVURLAsset(url: someURL)
guard let _ = audioAsset.tracks(withMediaType: .audio).first else { return }
let sourceAudioTrack = audioAsset.tracks(withMediaType: .audio)[0]
do {
    try compositionAudioTrack.insertTimeRange(sourceAudioTrack.timeRange, of: sourceAudioTrack, at: .zero)
} catch {
    print(error)
}

// create export session
guard let session = AVAssetExportSession(asset: mutableComposition, presetName: AVAssetExportPresetPassthrough) else {
    print("failed to prepare session")
    return
}

// set up output file
session.outputURL = urlToWriteTo
session.outputFileType = .mp4

// export the composition
session.exportAsynchronously {
    switch session.status {
    case .completed:
        print("completed")
    case .failed:
        print("export error: \(session.error!.localizedDescription)")
    default:
        break
    }
}

composition trackにassetのtrackを載せるコードのdo構文の中を以下のようにしてあげると、時間の設定を変えられます。

let firstFiveSeconds = CMTimeRange(start: .zero, end: CMTime(seconds: 5.0, preferredTimescale: sourceVideoTrack.naturalTimeScale))
try compositionVideoTrack.insertTimeRange(firstFiveSeconds, of: sourceVideoTrack, at: .zero)

AVMutableVideoCompositionInstruction, AVMutableVideoCompositionLayerInstruction

実際にトラックの操作(透過、移動、クロップ)を設定するにはこちらのクラスを利用します。
それらの操作の開始時間、継続時間も同時に設定します。
動画を撮影したときの向きを取得して、それに合わせてビデオトラックを回転させることもできます。(下のソースコードのlayerInstruction.setTransform(sourceVideoTrack.preferredTransform, at: .zero)の部分を参照してください。)
上の図では1つのAVMutableCompositionTrackに対して1つのAVMutableVideoCompositionLayerInstructionを使用しているイメージとなります。
しかし、1つのAVMutableCompositionTrackに対して複数のAVMutableVideoCompositionLayerInstructionを構築することも可能ですので、より複雑な編集も効率よく行えます。

AVMutableVideoComposition

AVMutableCompositionと名前は似ていますが、こちらのクラスではAVMutableCompositionに対する付加情報を設定していきます。
具体的には、フレームの長さやレンダリングサイズ、そして先ほどでてきたトラックの操作(AVMutableVideoCompositionInstruction)です。
このAVMutableVideoCompositionを、AVMutableCompositionとともにAVPlayerAVAssetExportSessionのクラスに渡す事でビデオに付加情報がセットされます。
このように説明だけしても、使い方が不明です、、、となりそうなのでまたソースコードを書いてみます。
先ほどのソースコードで生成したexport sessionであるsessionで出力を実行する前に、videoCompositionインスタンスを渡しています。

// create mutable video composition which mainly decides frame duration, render size and instruction
let videoComposition: AVMutableVideoComposition = AVMutableVideoComposition()

// set up frame size and render size
videoComposition.frameDuration = CMTimeMake(value: 1, timescale: 30)
videoComposition.renderSize = sourceVideoTrack.naturalSize

// create mutable video composition instruction
let instruction: AVMutableVideoCompositionInstruction = AVMutableVideoCompositionInstruction()

// set time range in which this instruction is active
instruction.timeRange = CMTimeRangeMake(start: CMTime.zero, duration: mutableComposition.duration)

// create and set up layer instruction
let layerInstruction: AVMutableVideoCompositionLayerInstruction = AVMutableVideoCompositionLayerInstruction(assetTrack: compositionVideoTrack)
layerInstruction.setTransform(sourceVideoTrack.preferredTransform, at: .zero) // rotate video here
instruction.layerInstructions = [layerInstruction]
videoComposition.instructions = [instruction]

// set mutable video composition in export session
session.videoComposition = videoComposition

// export below...

背景を挿入してみる

AVMutableVideoCompositionでは動画の上にアニメーションや字幕、動画と画像、動画と動画の重ね合わせを設定することもできます。
CALayerを用いることで映像に階層構造を作っています。
以下では映像の上に背景画像(静止画)を載せています。
これを上のソースコードの、sessionにvideo compositionを渡す前に書いてあげれば良いという感じです。

// get video size
let videoSize: CGSize = videoTrack.naturalSize
        
// create parent layer
let parentLayer: CALayer = CALayer()
parentLayer.frame = CGRect(x: 0, y: 0, width: videoSize.width, height: videoSize.height)

// create videp layer which will be associated with composition video track
let videoLayer: CALayer = CALayer()
videoLayer.frame = CGRect(x: 0, y: 0, width: videoSize.width, height: videoSize.height)

// add video layer to parent layer and attach video contents to video layer
parentLayer.addSublayer(videoLayer)
videoComposition.animationTool = AVVideoCompositionCoreAnimationTool(postProcessingAsVideoLayer: videoLayer, in: parentLayer)

// if original video selected, `backgroundImage` is nil and background layer shouldn't be created or added to parent layer
if let _ = backgroundImage {
    // create background layer
    let backgroundLayer: CALayer = CALayer()
    backgroundLayer.frame = CGRect(x: 0, y: 0, width: videoSize.width, height: videoSize.height)
    backgroundLayer.opacity = 1.0
    backgroundLayer.masksToBounds = true
    backgroundLayer.backgroundColor = UIColor.clear.cgColor
    backgroundLayer.contentsGravity = CALayerContentsGravity.resizeAspectFill
    
    // add contents to background layer
    backgroundLayer.contents = backgroundImage!.cgImage
    
    // add background layer over video layer
    parentLayer.addSublayer(backgroundLayer)
    
    // make video layer clear to lighten video
    videoLayer.opacity = 0
}

自作の字幕を挿入してみる

開発中の製品の売りは歌詞入れ機能!ということで歌詞の入れ方も記録しておきます。
アプリ内では歌詞を設定して音楽を聴きながら、歌詞に触れていた時間だけその歌詞を挿入するという機能を実装しています。
実際のプロダクトのコードです。
mergeMovie()メソッドを呼ぶと歌詞のついた動画がアプリのtmpディレクトリとフォトライブラリに保存されるようになっています。
ここでlyricDataプロパティは前の画面から引き継いでいるもので、型はDictionary<String, Any>ですが、実際には["id": Int, "lyric": String, "start_at": CMTime, "end_at": CMTime]というような形式です。
getFromTmp(file:)メソッドは独自に定義したものなので完全コピペでは走りません。

`getFromTmp(file:)`メソッドのソースコード
import Foundation

extension FileManager {
    class func getFromTmp(file name: String) -> URL {
        let tmpDirURL = self.default.temporaryDirectory
        let url = tmpDirURL.appendingPathComponent(name)
        if self.default.fileExists(atPath: url.path) {
            try! self.default.removeItem(atPath: url.path)
            print("this file \(name) already exists and then deleted")
        }
        return url
    }
}
private func mergeMovie() {
    // confirm source video asset is not nil
    let asset = AVURLAsset(url: movieURL)
    print(movieURL.path)
    
    // extract video track from asset
    guard let _ = asset.tracks(withMediaType: .video).first else { return print("video track not found") }
    let videoTrack = asset.tracks(withMediaType: AVMediaType.video)[0]
    
    // extract audio track from asset
    guard let _ = asset.tracks(withMediaType: .audio).first else { return print("audio track not found") }
    let audioTrack = asset.tracks(withMediaType: AVMediaType.audio)[0]
    
    // create empty base composition
    let mutableComposition: AVMutableComposition = AVMutableComposition()
    
    // create empty composition video and audio tracks
    let compositionVideoTrack: AVMutableCompositionTrack! = mutableComposition.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid)
    let compositionAudioTrack: AVMutableCompositionTrack! = mutableComposition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid)
    
    // insert source video track to the composition video track
    do {
        try compositionVideoTrack.insertTimeRange(CMTimeRangeMake(start: CMTime.zero, duration: asset.duration), of: videoTrack, at: CMTime.zero)
    } catch {
        print("insert video track error:", error)
    }
    
    // insert source audio track to the composition audio track
    do {
        try compositionAudioTrack.insertTimeRange(CMTimeRangeMake(start: CMTime.zero, duration: asset.duration), of: audioTrack, at: CMTime.zero)
    } catch {
        print("insert audio track error:", error)
    }
    // 回転方向の設定
    let preferredTransform = videoTrack.preferredTransform
    //        compositionVideoTrack.preferredTransform = preferredTransform // not effective
    
    // create eport session with base composition
    _assetExportSession = AVAssetExportSession(asset: mutableComposition, presetName: AVAssetExportPresetMediumQuality)
    
    // create mutable video composition which mainly decides duration, render size and instruction
    let videoComposition: AVMutableVideoComposition = AVMutableVideoComposition()
    videoComposition.renderSize = videoTrack.naturalSize
    videoComposition.frameDuration = CMTimeMake(value: 1, timescale: 30)
    
    let instruction: AVMutableVideoCompositionInstruction = AVMutableVideoCompositionInstruction()
    instruction.timeRange = CMTimeRangeMake(start: CMTime.zero, duration: mutableComposition.duration)
    let layerInstruction: AVMutableVideoCompositionLayerInstruction = AVMutableVideoCompositionLayerInstruction(assetTrack: compositionVideoTrack)
    layerInstruction.setTransform(preferredTransform, at: .zero) // required!
    instruction.layerInstructions = [layerInstruction]
    videoComposition.instructions = [instruction]
    
    let videoSize: CGSize = mutableComposition.naturalSize
    
    // create lyric layer
    let lyricLayer = self.makeLyricLayer(for: mutableComposition)
    
    // create parent layer
    let parentLayer: CALayer = CALayer()
    parentLayer.frame = CGRect(x: 0, y: 0, width: videoSize.width, height: videoSize.height)
    
    let videoLayer: CALayer = CALayer()
    videoLayer.frame = CGRect(x: 0, y: 0, width: videoSize.width, height: videoSize.height)
    
    parentLayer.addSublayer(videoLayer)
    parentLayer.addSublayer(lyricLayer)
    videoComposition.animationTool = AVVideoCompositionCoreAnimationTool(postProcessingAsVideoLayer: videoLayer, in: parentLayer)
    
    // set mutable video composition in export session
    _assetExportSession?.videoComposition = videoComposition
    
    // set up export session
    exportURL = FileManager.getFromTmp(file: "completion_movie.mov")
    _assetExportSession?.outputFileType = AVFileType.mov
    _assetExportSession?.outputURL = exportURL
    _assetExportSession?.shouldOptimizeForNetworkUse = true
    
    // export
    _assetExportSession?.exportAsynchronously(completionHandler: {() -> Void in
        if self._assetExportSession?.status == AVAssetExportSession.Status.failed {
            print("failed:", self._assetExportSession?.error ?? "error")
        }
        if self._assetExportSession?.status == AVAssetExportSession.Status.completed {
            // save to photo library
            PHPhotoLibrary.shared().performChanges({
                PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: self.exportURL)
            })
            print("saved to \(self._assetExportSession!.outputURL!.path)")
        }
    })
}

private func makeLyricLayer(for mutableComposition: AVMutableComposition) -> CALayer {
    let videoSize = mutableComposition.naturalSize
    
    // create parent layer
    let lyricLayer: CALayer = CALayer()
    lyricLayer.frame = CGRect(x: 0, y: 0, width: videoSize.width, height: videoSize.height)
    lyricLayer.opacity = 1.0
    lyricLayer.backgroundColor = UIColor.clear.cgColor
    lyricLayer.masksToBounds = true
    
    // prepare animation
    let frameAnimation: CAKeyframeAnimation = CAKeyframeAnimation(keyPath: "contents")
    frameAnimation.beginTime = AVCoreAnimationBeginTimeAtZero // attention! apple recommends
    frameAnimation.duration = CMTimeGetSeconds(mutableComposition.duration)
    frameAnimation.repeatCount = 1
    frameAnimation.autoreverses = false
    frameAnimation.isRemovedOnCompletion = false // apple recommends
    frameAnimation.fillMode = CAMediaTimingFillMode.forwards
    frameAnimation.calculationMode = CAAnimationCalculationMode.discrete
    
    // set up key times
    var imageKeyTimes: Array<NSNumber> = []
    let frameCount = Int(frameAnimation.duration * 30) // duration [s] * frame rate [/s] = total frame count
    for i in 0 ... frameCount {
        imageKeyTimes.append((Double(i)/Double(frameCount)) as NSNumber)
    }
    frameAnimation.keyTimes = imageKeyTimes
    
    // set up values
    var imageValues: Array<CGImage> = []
    for currentFrame in 0 ... frameCount {
        // begin rendering setting
        UIGraphicsBeginImageContext(videoSize)
        // get position of crrent frame in total video length
        let ratio = Double(currentFrame) / Double(frameCount)
        
        for (index, lyricDatum) in lyricData.enumerated() {
            let startTime = lyricDatum["start_at"] as! CMTime // time at which the lyric appears
            let endTime = lyricDatum["end_at"] as! CMTime // time at which lyric disappears
            // if current frame is between start time and end time, render lyric label
            if CMTimeGetSeconds(startTime) / CMTimeGetSeconds(mutableComposition.duration) < ratio && ratio < CMTimeGetSeconds(endTime) / CMTimeGetSeconds(mutableComposition.duration) {
                let lyric = lyricDatum["lyric"] as! String
                let attributedString = NSMutableAttributedString(string: lyric, attributes: [NSAttributedString.Key.font: UIFont.systemFont(ofSize: 36, weight: UIFont.Weight(rawValue: 1)), NSAttributedString.Key.foregroundColor: UIColor.white])
                let label = UILabel()
                label.frame.size = videoSize
                label.textAlignment = .center
                label.numberOfLines = 2
                label.clipsToBounds = true
                label.allowsDefaultTighteningForTruncation = true
                label.attributedText = attributedString
                label.drawText(in: CGRect(x: lyricLayer.bounds.origin.x, y: lyricLayer.bounds.maxY * 2 / 3, width: videoSize.width, height: videoSize.height / 3))
            }
            // else render nothing
        }
        
        let lyricImage = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()
        
        guard let _ = lyricImage?.cgImage else { continue }
        imageValues.append(lyricImage!.cgImage!)
    }
    frameAnimation.values = imageValues
    
    // add animation to lyric layer
    lyricLayer.add(frameAnimation, forKey: nil)
    
    return lyricLayer
}

このコードだと重すぎたので、滑らかに動くアニメーションを挿入するのであれば別ですが、字幕を入れるだけであれば、下のコードの方が良いです。

private func makeLyricLayer(for mutableComposition: AVMutableComposition) -> CALayer {
    let videoSize = mutableComposition.naturalSize
    
    // create parent layer
    let lyricLayer: CALayer = CALayer()
    lyricLayer.frame = CGRect(x: 0, y: 0, width: videoSize.width, height: videoSize.height)
    lyricLayer.opacity = 1.0
    lyricLayer.backgroundColor = UIColor.clear.cgColor
    lyricLayer.masksToBounds = true
    
    // prepare animation
    let frameAnimation: CAKeyframeAnimation = CAKeyframeAnimation(keyPath: "contents")
    frameAnimation.beginTime = AVCoreAnimationBeginTimeAtZero // attention! apple recommends
    frameAnimation.duration = CMTimeGetSeconds(mutableComposition.duration)
    frameAnimation.repeatCount = 1
    frameAnimation.autoreverses = false
    frameAnimation.isRemovedOnCompletion = false // apple recommends
    frameAnimation.fillMode = CAMediaTimingFillMode.forwards
    frameAnimation.calculationMode = CAAnimationCalculationMode.discrete
    
    // set up key times
    var imageKeyTimes: Array<NSNumber> = []
    
    // set up values
    var imageValues: Array<CGImage> = []
    
    // create transparent image
    UIGraphicsBeginImageContext(videoSize)
    guard let cgEmptyImage = UIGraphicsGetImageFromCurrentImageContext()?.cgImage else { return lyricLayer }
    UIGraphicsEndImageContext()
    
    lyricData.sort() { d0, d1 in
        let startTime0 = d0["start_at"] as! CMTime
        let startTime1 = d1["start_at"] as! CMTime
        return startTime0 < startTime1
    }
    
    for (index, lyricDatum) in lyricData.enumerated() {
        let startTime = lyricDatum["start_at"] as! CMTime // time at which the lyric appears
        let endTime = lyricDatum["end_at"] as! CMTime // time at which lyric disappears
        
        if startTime == endTime { continue }
        
        if index == 0, startTime != .zero {
            imageKeyTimes.append(0)
            imageValues.append(cgEmptyImage)
        }
        
        // if current frame is between start time and end time, render lyric label
        imageKeyTimes.append(NSNumber(value: CMTimeGetSeconds(startTime) / CMTimeGetSeconds(mutableComposition.duration)))
        imageKeyTimes.append(NSNumber(value: CMTimeGetSeconds(endTime) / CMTimeGetSeconds(mutableComposition.duration)))
        
        // begin rendering
        UIGraphicsBeginImageContext(videoSize)
                    
        let lyric = lyricDatum["lyric"] as! String
        let attributedString = NSMutableAttributedString(string: lyric, attributes: [NSAttributedString.Key.font: UIFont.systemFont(ofSize: 24), NSAttributedString.Key.foregroundColor: UIColor.white])
        let label = UILabel()
        label.frame.size = videoSize
        label.textAlignment = .center
        label.numberOfLines = 2
        label.clipsToBounds = true
        label.allowsDefaultTighteningForTruncation = true
        label.attributedText = attributedString
        label.drawText(in: CGRect(x: lyricLayer.bounds.origin.x, y: lyricLayer.bounds.maxY * 2 / 3, width: videoSize.width, height: videoSize.height / 3))
        
        guard let cgLyricImage = UIGraphicsGetImageFromCurrentImageContext()?.cgImage else {
            UIGraphicsEndImageContext()
            imageValues.append(cgEmptyImage)
            imageValues.append(cgEmptyImage)
            continue
        }
        
        // end rendering
        UIGraphicsEndImageContext()
        
        imageValues.append(cgLyricImage)
        imageValues.append(cgEmptyImage)
    }
    
    // set the last key time and the last value
    imageKeyTimes.append(1)
    imageValues.append(cgEmptyImage)
    
    // set key times
    frameAnimation.keyTimes = imageKeyTimes
    print(imageKeyTimes)
    
    // set values
    frameAnimation.values = imageValues
    print(imageValues)
    
    // add animation to lyric layer
    lyricLayer.add(frameAnimation, forKey: nil)
    
    return lyricLayer
}
3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?