Last active
May 3, 2024 00:15
-
-
Save yashthaker7/89d153fe9b1e10505237a2994a73ac33 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// TYAudioVideoManager.swift | |
// Yash Thaker | |
// | |
// Created by Yash Thaker on 05/12/18. | |
// Copyright © 2018 Yash Thaker. All rights reserved. | |
// | |
/* | |
1) mergeVideos(videoUrls: [URL], exportUrl: URL, preset: String? = nil, progress: @escaping Progress, completion: @escaping Completion) | |
2) mergeAudioTo(videoUrl: URL, audioUrl: URL, exportUrl: URL, progress: @escaping Progress, completion: @escaping Completion) | |
3) changeVideoSpeed(videoUrl: URL, speedMode: SpeedMode, exportUrl: URL, progress: @escaping Progress, completion: @escaping Completion) | |
4) trimVideo(videoUrl: URL, startTime: CMTime, endTime: CMTime, exportUrl: URL, progress: @escaping Progress, completion: @escaping Completion) | |
5) addLayerToVideo(videoUrl: URL, overlayImage: UIImage, exportUrl: URL, preset: String? = nil, progress: @escaping Progress, completion: @escaping Completion) | |
6) applyFilterToVideo(_ videoUrl: URL, filter: CIFilter, exportUrl: URL, preset: String? = nil, progress: @escaping Progress, completion: @escaping Completion) | |
7) addWatermark(videoUrl: URL, watermarkImage: UIImage, exportUrl: URL, preset: String? = nil, progress: @escaping Progress, completion: @escaping Completion) | |
*/ | |
import UIKit | |
import AVFoundation | |
enum SpeedMode { | |
case faster | |
case slower | |
} | |
class TYAudioVideoManager: NSObject { | |
static let shared = TYAudioVideoManager() | |
typealias Completion = (URL?, Error?) -> Void | |
typealias Progress = (Float) -> Void | |
func mergeVideos(videoUrls: [URL], exportUrl: URL, preset: String? = nil, progress: @escaping Progress, completion: @escaping Completion) { | |
let videoAssets: [AVAsset] = videoUrls.map { (url) -> AVAsset in | |
return AVAsset(url: url) | |
} | |
var insertTime = CMTime.zero | |
var arrayLayerInstructions: [AVMutableVideoCompositionLayerInstruction] = [] | |
var outputSize = CGSize.init(width: 0, height: 0) | |
// Determine video output size | |
for videoAsset in videoAssets { | |
guard let videoTrack = videoAsset.tracks(withMediaType: .video).first else { return } | |
let assetInfo = orientationFromTransform(transform: videoTrack.preferredTransform) | |
var videoSize = videoTrack.naturalSize | |
if assetInfo.isPortrait == true { | |
videoSize.width = videoTrack.naturalSize.height | |
videoSize.height = videoTrack.naturalSize.width | |
} | |
if videoSize.height > outputSize.height { | |
outputSize = videoSize | |
} | |
} | |
if outputSize.width == 0 || outputSize.height == 0 { | |
outputSize = CGSize(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height) | |
} | |
// Silence sound (in case of video has no sound track) | |
let blankAudioUrl = Bundle.main.url(forResource: "blank", withExtension: "wav")! | |
let blankAudio = AVAsset(url: blankAudioUrl) | |
let blankAudioTrack = blankAudio.tracks(withMediaType: .audio).first | |
// Init composition | |
let mixComposition = AVMutableComposition.init() | |
for videoAsset in videoAssets { | |
// Get video track | |
guard let videoTrack = videoAsset.tracks(withMediaType: .video).first else { continue } | |
// Get audio track | |
var audioTrack: AVAssetTrack? | |
if videoAsset.tracks(withMediaType: .audio).count > 0 { | |
audioTrack = videoAsset.tracks(withMediaType: .audio).first | |
} else { | |
audioTrack = blankAudioTrack | |
} | |
// Init video & audio composition track | |
let videoCompositionTrack = mixComposition.addMutableTrack(withMediaType: .video, preferredTrackID: Int32(kCMPersistentTrackID_Invalid)) | |
let audioCompositionTrack = mixComposition.addMutableTrack(withMediaType: .audio, preferredTrackID: Int32(kCMPersistentTrackID_Invalid)) | |
do { | |
let startTime = CMTime.zero | |
let duration = videoAsset.duration | |
// Add video track to video composition at specific time | |
try videoCompositionTrack?.insertTimeRange(CMTimeRangeMake(start: startTime, duration: duration), of: videoTrack, at: insertTime) | |
// Add audio track to audio composition at specific time | |
if let audioTrack = audioTrack { | |
try audioCompositionTrack?.insertTimeRange(CMTimeRangeMake(start: startTime, duration: duration), of: audioTrack, at: insertTime) | |
} | |
// Add instruction for video track | |
let layerInstruction = videoCompositionInstructionForTrack(track: videoCompositionTrack!, asset: videoAsset, standardSize: outputSize, atTime: insertTime) | |
// Hide video track before changing to new track | |
let endTime = CMTimeAdd(insertTime, duration) | |
layerInstruction.setOpacity(0, at: endTime) | |
arrayLayerInstructions.append(layerInstruction) | |
// Increase the insert time | |
insertTime = CMTimeAdd(insertTime, duration) | |
} catch { | |
print("Load track error") | |
} | |
} | |
// Main video composition instruction | |
let mainInstruction = AVMutableVideoCompositionInstruction() | |
mainInstruction.timeRange = CMTimeRangeMake(start: .zero, duration: insertTime) | |
mainInstruction.layerInstructions = arrayLayerInstructions | |
// Main video composition | |
let mainComposition = AVMutableVideoComposition() | |
mainComposition.instructions = [mainInstruction] | |
mainComposition.frameDuration = CMTimeMake(value: 1, timescale: 30) | |
mainComposition.renderSize = outputSize | |
startExport(mixComposition, mainComposition, exportUrl, preset: (preset ?? AVAssetExportPreset1920x1080), progress: progress, completion: completion) | |
} | |
func mergeAudioTo(videoUrl: URL, audioUrl: URL, exportUrl: URL, progress: @escaping Progress, completion: @escaping Completion) { | |
let videoAsset = AVAsset(url: videoUrl) | |
let audioAsset = AVAsset(url: audioUrl) | |
// Init composition | |
let mixComposition = AVMutableComposition.init() | |
// Get video track | |
guard let videoTrack = videoAsset.tracks(withMediaType: .video).first else { | |
print("[Error]: Video not found.") | |
completion(nil, nil) | |
return | |
} | |
// Get audio track | |
guard let audioTrack = audioAsset.tracks(withMediaType: .audio).first else { | |
print("[Error]: Audio not found.") | |
completion(nil, nil) | |
return | |
} | |
// Init video & audio composition track | |
let videoCompositionTrack = mixComposition.addMutableTrack(withMediaType: .video, | |
preferredTrackID: Int32(kCMPersistentTrackID_Invalid)) | |
let audioCompositionTrack = mixComposition.addMutableTrack(withMediaType: .audio, | |
preferredTrackID: Int32(kCMPersistentTrackID_Invalid)) | |
do { | |
// Add video track to video composition at specific time | |
try videoCompositionTrack?.insertTimeRange(CMTimeRangeMake(start: .zero, duration: videoAsset.duration), of: videoTrack, at: .zero) | |
// Add audio track to audio composition at specific time | |
try audioCompositionTrack?.insertTimeRange(CMTimeRangeMake(start: .zero, duration: videoAsset.duration), of: audioTrack, at: .zero) | |
} catch { | |
print(error.localizedDescription) | |
} | |
startExport(mixComposition, nil, exportUrl, progress: progress, completion: completion) | |
} | |
func changeVideoSpeed(videoUrl: URL, speedMode: SpeedMode, exportUrl: URL, progress: @escaping Progress, completion: @escaping Completion) { | |
let videoAsset = AVAsset(url: videoUrl) | |
// Init composition | |
let mixComposition = AVMutableComposition.init() | |
// Get video track | |
guard let videoTrack = videoAsset.tracks(withMediaType: .video).first else { | |
print("[Error]: Video not found.") | |
completion(nil, nil) | |
return | |
} | |
// Get audio track | |
var audioTrack: AVAssetTrack? | |
if videoAsset.tracks(withMediaType: .audio).count > 0 { | |
audioTrack = videoAsset.tracks(withMediaType: .audio).first | |
} | |
// Init video & audio composition track | |
let videoCompositionTrack = mixComposition.addMutableTrack(withMediaType: .video, | |
preferredTrackID: Int32(kCMPersistentTrackID_Invalid)) | |
let audioCompositionTrack = mixComposition.addMutableTrack(withMediaType: .audio, | |
preferredTrackID: Int32(kCMPersistentTrackID_Invalid)) | |
// slowdown or fast forward | |
let finalVideoDuration = speedMode == .faster ? videoAsset.duration.value / 2 : videoAsset.duration.value * 2 | |
let scaledVideoDuration = CMTimeMake(value: finalVideoDuration, timescale: videoAsset.duration.timescale) | |
do { | |
// Add video track to video composition at specific time | |
try videoCompositionTrack?.insertTimeRange(CMTimeRangeMake(start: .zero, duration: videoAsset.duration), of: videoTrack, at: .zero) | |
videoCompositionTrack?.scaleTimeRange(CMTimeRangeMake(start: .zero, duration: videoAsset.duration), toDuration: scaledVideoDuration) | |
// Add audio track to audio composition at specific time | |
if let audioTrack = audioTrack { | |
try audioCompositionTrack?.insertTimeRange(CMTimeRangeMake(start: .zero, duration: videoAsset.duration), of: audioTrack, at: .zero) | |
audioCompositionTrack?.scaleTimeRange(CMTimeRangeMake(start: .zero, duration: videoAsset.duration), toDuration: scaledVideoDuration) | |
} | |
} catch { | |
print(error.localizedDescription) | |
} | |
startExport(mixComposition, nil, exportUrl, progress: progress, completion: completion) | |
} | |
func trimVideo(videoUrl: URL, startTime: CMTime, endTime: CMTime, exportUrl: URL, progress: @escaping Progress, completion: @escaping Completion) { | |
let videoAsset = AVAsset(url: videoUrl) | |
let timeRange = CMTimeRange(start: startTime, end: endTime) | |
startExport(videoAsset, nil, exportUrl, timeRange: timeRange, progress: progress, completion: completion) | |
} | |
func addLayerToVideo(videoUrl: URL, overlayImage: UIImage, exportUrl: URL, preset: String? = nil, progress: @escaping Progress, completion: @escaping Completion) { | |
let videoAsset = AVAsset(url: videoUrl) | |
var outputSize = CGSize.init(width: 0, height: 0) | |
var arrayLayerInstructions: [AVMutableVideoCompositionLayerInstruction] = [] | |
// Determine video output size | |
guard let videoTrack = videoAsset.tracks(withMediaType: .video).first else { return } | |
let assetInfo = orientationFromTransform(transform: videoTrack.preferredTransform) | |
var videoSize = videoTrack.naturalSize | |
if assetInfo.isPortrait == true { | |
videoSize.width = videoTrack.naturalSize.height | |
videoSize.height = videoTrack.naturalSize.width | |
} | |
if videoSize.height > outputSize.height { | |
outputSize = videoSize | |
} | |
if outputSize.width == 0 || outputSize.height == 0 { | |
outputSize = CGSize(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height) | |
} | |
// Silence sound (in case of video has no sound track) | |
let blankAudioUrl = Bundle.main.url(forResource: "blank", withExtension: "wav")! | |
let blankAudio = AVAsset(url: blankAudioUrl) | |
let blankAudioTrack = blankAudio.tracks(withMediaType: .audio).first | |
// Init composition | |
let mixComposition = AVMutableComposition.init() | |
// Get audio track | |
var audioTrack: AVAssetTrack? | |
if videoAsset.tracks(withMediaType: .audio).count > 0 { | |
audioTrack = videoAsset.tracks(withMediaType: .audio).first | |
} else { | |
audioTrack = blankAudioTrack | |
} | |
// Init video & audio composition track | |
let videoCompositionTrack = mixComposition.addMutableTrack(withMediaType: .video, preferredTrackID: Int32(kCMPersistentTrackID_Invalid)) | |
let audioCompositionTrack = mixComposition.addMutableTrack(withMediaType: .audio, preferredTrackID: Int32(kCMPersistentTrackID_Invalid)) | |
do { | |
// Add video track to video composition at specific time | |
try videoCompositionTrack?.insertTimeRange(CMTimeRangeMake(start: .zero, duration: videoAsset.duration), of: videoTrack, at: .zero) | |
// Add audio track to audio composition at specific time | |
if let audioTrack = audioTrack { | |
try audioCompositionTrack?.insertTimeRange(CMTimeRangeMake(start: .zero, duration: videoAsset.duration), of: audioTrack, at: .zero) | |
} | |
// Add instruction for video track | |
let layerInstruction = videoCompositionInstructionForTrack(track: videoCompositionTrack!, asset: videoAsset, standardSize: outputSize, atTime: .zero) | |
// Hide video track before changing to new track | |
let endTime = CMTimeAdd(.zero, videoAsset.duration) | |
layerInstruction.setOpacity(0, at: endTime) | |
arrayLayerInstructions.append(layerInstruction) | |
} catch { | |
completion(exportUrl,error) | |
return | |
} | |
// Main video composition instruction | |
let mainInstruction = AVMutableVideoCompositionInstruction() | |
mainInstruction.timeRange = CMTimeRangeMake(start: .zero, duration: videoAsset.duration) | |
mainInstruction.layerInstructions = arrayLayerInstructions | |
// Main video composition | |
let mainComposition = AVMutableVideoComposition() | |
mainComposition.instructions = [mainInstruction] | |
mainComposition.frameDuration = CMTimeMake(value: 1, timescale: 30) | |
mainComposition.renderSize = outputSize | |
let parentLayer = CALayer.init() | |
parentLayer.frame = CGRect(x: 0, y: 0, width: outputSize.width, height: outputSize.height) | |
let videoLayer = CALayer.init() | |
videoLayer.frame = CGRect(x: 0, y: 0, width: outputSize.width, height: outputSize.height) | |
parentLayer.addSublayer(videoLayer) | |
// add image | |
let overlayLayer = CALayer.init() | |
overlayLayer.contentsGravity = .resizeAspect | |
overlayLayer.contents = overlayImage.cgImage | |
overlayLayer.frame = CGRect(x: 0, y: 0, width: outputSize.width, height: outputSize.height) | |
overlayLayer.masksToBounds = true | |
parentLayer.addSublayer(overlayLayer) | |
mainComposition.animationTool = AVVideoCompositionCoreAnimationTool.init(postProcessingAsVideoLayer: videoLayer, in: parentLayer) | |
startExport(mixComposition, mainComposition, exportUrl, preset: (preset ?? AVAssetExportPreset1920x1080), progress: progress, completion: completion) | |
} | |
func applyFilterToVideo(_ videoUrl: URL, filter: CIFilter, exportUrl: URL, preset: String? = nil, progress: @escaping Progress, completion: @escaping Completion) { | |
let videoAsset = AVAsset(url: videoUrl) | |
var context: CIContext! | |
if let device = MTLCreateSystemDefaultDevice() { | |
context = CIContext(mtlDevice: device) | |
} else { | |
context = CIContext() | |
} | |
let videoComposition = AVMutableVideoComposition(asset: videoAsset, applyingCIFiltersWithHandler: { request in | |
let source = request.sourceImage.clampedToExtent() | |
filter.setValue(source, forKey: kCIInputImageKey) | |
let outputImage = filter.outputImage?.cropped(to: source.extent) ?? source | |
request.finish(with: outputImage, context: context) | |
}) | |
startExport(videoAsset, videoComposition, exportUrl, preset: (preset ?? AVAssetExportPreset1920x1080), progress: progress, completion: completion) | |
} | |
func addWatermark(videoUrl: URL, watermarkImage: UIImage, exportUrl: URL, preset: String? = nil, progress: @escaping Progress, completion: @escaping Completion) { | |
guard let watermarkCIImage = CIImage(image: watermarkImage) else { | |
print("[Error]: watermarkCIImage could not create.") | |
return | |
} | |
let videoAsset = AVAsset(url: videoUrl) | |
var context: CIContext! | |
if let device = MTLCreateSystemDefaultDevice() { | |
context = CIContext(mtlDevice: device) | |
} else { | |
context = CIContext() | |
} | |
let overCompositingFilter = CIFilter(name: "CISourceOverCompositing")! | |
let videoComposition = AVMutableVideoComposition(asset: videoAsset) { (request) in | |
let source = request.sourceImage.clampedToExtent() | |
overCompositingFilter.setValue(source, forKey: kCIInputBackgroundImageKey) | |
overCompositingFilter.setValue(watermarkCIImage, forKey: kCIInputImageKey) | |
let outputImage = overCompositingFilter.outputImage?.cropped(to: source.extent) ?? source | |
request.finish(with: outputImage, context: context) | |
} | |
startExport(videoAsset, videoComposition, exportUrl, preset: (preset ?? AVAssetExportPreset1920x1080), progress: progress, completion: completion) | |
} | |
deinit { print("TYAudioVideoManager deinit.") } | |
} | |
// MARK:- Private methods | |
extension TYAudioVideoManager { | |
fileprivate func orientationFromTransform(transform: CGAffineTransform) -> (orientation: UIImage.Orientation, isPortrait: Bool) { | |
var assetOrientation = UIImage.Orientation.up | |
var isPortrait = false | |
if transform.a == 0 && transform.b == 1.0 && transform.c == -1.0 && transform.d == 0 { | |
assetOrientation = .right | |
isPortrait = true | |
} else if transform.a == 0 && transform.b == -1.0 && transform.c == 1.0 && transform.d == 0 { | |
assetOrientation = .left | |
isPortrait = true | |
} else if transform.a == 1.0 && transform.b == 0 && transform.c == 0 && transform.d == 1.0 { | |
assetOrientation = .up | |
} else if transform.a == -1.0 && transform.b == 0 && transform.c == 0 && transform.d == -1.0 { | |
assetOrientation = .down | |
} | |
return (assetOrientation, isPortrait) | |
} | |
fileprivate func videoCompositionInstructionForTrack(track: AVCompositionTrack, asset: AVAsset, standardSize: CGSize, atTime: CMTime) -> AVMutableVideoCompositionLayerInstruction { | |
let instruction = AVMutableVideoCompositionLayerInstruction(assetTrack: track) | |
let assetTrack = asset.tracks(withMediaType: AVMediaType.video)[0] | |
let transform = assetTrack.preferredTransform | |
let assetInfo = orientationFromTransform(transform: transform) | |
var aspectFillRatio:CGFloat = 1 | |
if assetTrack.naturalSize.height < assetTrack.naturalSize.width { | |
aspectFillRatio = standardSize.height / assetTrack.naturalSize.height | |
} else { | |
aspectFillRatio = standardSize.width / assetTrack.naturalSize.width | |
} | |
if assetInfo.isPortrait { | |
let scaleFactor = CGAffineTransform(scaleX: aspectFillRatio, y: aspectFillRatio) | |
let posX = standardSize.width/2 - (assetTrack.naturalSize.height * aspectFillRatio)/2 | |
let posY = standardSize.height/2 - (assetTrack.naturalSize.width * aspectFillRatio)/2 | |
let moveFactor = CGAffineTransform(translationX: posX, y: posY) | |
instruction.setTransform(assetTrack.preferredTransform.concatenating(scaleFactor).concatenating(moveFactor), at: atTime) | |
} else { | |
let scaleFactor = CGAffineTransform(scaleX: aspectFillRatio, y: aspectFillRatio) | |
let posX = standardSize.width/2 - (assetTrack.naturalSize.width * aspectFillRatio)/2 | |
let posY = standardSize.height/2 - (assetTrack.naturalSize.height * aspectFillRatio)/2 | |
let moveFactor = CGAffineTransform(translationX: posX, y: posY) | |
var concat = assetTrack.preferredTransform.concatenating(scaleFactor).concatenating(moveFactor) | |
if assetInfo.orientation == .down { | |
let fixUpsideDown = CGAffineTransform(rotationAngle: CGFloat(Double.pi)) | |
concat = fixUpsideDown.concatenating(scaleFactor).concatenating(moveFactor) | |
} | |
instruction.setTransform(concat, at: atTime) | |
} | |
return instruction | |
} | |
fileprivate func setOrientation(image: UIImage?, onLayer: CALayer, outputSize: CGSize) -> Void { | |
guard let image = image else { return } | |
if image.imageOrientation == UIImage.Orientation.up { | |
// Do nothing | |
} else if image.imageOrientation == UIImage.Orientation.left { | |
let rotate = CGAffineTransform(rotationAngle: .pi/2) | |
onLayer.setAffineTransform(rotate) | |
} else if image.imageOrientation == UIImage.Orientation.down { | |
let rotate = CGAffineTransform(rotationAngle: .pi) | |
onLayer.setAffineTransform(rotate) | |
} else if image.imageOrientation == UIImage.Orientation.right { | |
let rotate = CGAffineTransform(rotationAngle: -.pi/2) | |
onLayer.setAffineTransform(rotate) | |
} | |
} | |
fileprivate func startExport(_ mixComposition: AVAsset, _ videoComposition: AVMutableVideoComposition? = nil, _ exportUrl: URL, preset: String? = nil, timeRange: CMTimeRange? = nil, progress: @escaping Progress, completion: @escaping Completion) { | |
// Init exporter | |
let exporter = AVAssetExportSession.init(asset: mixComposition, presetName: (preset ?? AVAssetExportPresetPassthrough)) | |
exporter?.outputURL = exportUrl | |
exporter?.outputFileType = AVFileType.mov | |
exporter?.shouldOptimizeForNetworkUse = true | |
exporter?.videoComposition = videoComposition | |
if let timeRange = timeRange { | |
exporter?.timeRange = timeRange | |
} | |
let progressTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true, block: { (timer) in | |
let exportProgress: Float = exporter?.progress ?? 0.0 | |
DispatchQueue.main.async { | |
progress(exportProgress) | |
} | |
}) | |
// let exportQueue = DispatchQueue(label: "VideoExportProgressQueue") | |
// exportQueue.async(execute: { | |
// while exporter != nil { | |
// let exportProgress: Float = exporter?.progress ?? 0.0 | |
// DispatchQueue.main.async { | |
// progress(exportProgress) | |
// } | |
// if exportProgress == 1.0 { | |
// break | |
// } | |
// } | |
// }) | |
// Do export | |
exporter?.exportAsynchronously(completionHandler: { | |
progressTimer.invalidate() | |
if exporter?.status == AVAssetExportSession.Status.completed { | |
print("Exported file: \(exportUrl.absoluteString)") | |
DispatchQueue.main.async { | |
completion(exportUrl, nil) | |
} | |
} else if exporter?.status == AVAssetExportSession.Status.failed { | |
DispatchQueue.main.async { | |
completion(exportUrl, exporter?.error) | |
} | |
} | |
}) | |
} | |
} | |
extension FileManager { | |
func removeItemIfExisted(_ url: URL) { | |
if FileManager.default.fileExists(atPath: url.path) { | |
do { | |
try FileManager.default.removeItem(atPath: url.path) | |
} catch { | |
print("Failed to delete file") | |
} | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
@yashthaker Do you have any documentation on how to implement this in a video recording project? Yours is the best one I've seen so far, however I'm not sure how to put it all together in my current project.