-
-
Save rajatmohanty/4e07b21a29abdcc57677e39e2cf40f3b 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
// Download the "video GIF" | |
let data = try! Data(contentsOf: URL(string: "https://thumbs.gfycat.com/ThankfulLawfulAsianelephant-mobile.mp4")!) | |
let fileName = String(format: "%@_%@", ProcessInfo.processInfo.globallyUniqueString, "html5gif.mp4") | |
let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(fileName) | |
try! data.write(to: url, options: [.atomic]) | |
// Do the converties | |
let asset = AVURLAsset(url: url) | |
guard let reader = try? AVAssetReader(asset: asset) else { | |
return | |
} | |
guard let videoTrack = asset.tracks(withMediaType: .video).first else { | |
return | |
} | |
let videoSize = videoTrack.naturalSize.applying(videoTrack.preferredTransform) | |
// Restrict it to 480p (max in either dimension), it's a GIF, no need to have it in crazy 1080p (saves encoding time a lot, too) | |
let cappedResolution: CGFloat = 480.0 | |
let aspectRatio = videoSize.width / videoSize.height | |
let resultingSize: CGSize = { | |
if videoSize.width > videoSize.height { | |
let cappedWidth = round(min(cappedResolution, videoSize.width)) | |
return CGSize(width: cappedWidth, height: round(cappedWidth / aspectRatio)) | |
} else { | |
let cappedHeight = round(min(cappedResolution, videoSize.height)) | |
return CGSize(width: round(cappedHeight * aspectRatio), height: cappedHeight) | |
} | |
}() | |
let duration: CGFloat = CGFloat(asset.duration.seconds) | |
let nominalFrameRate = CGFloat(videoTrack.nominalFrameRate) | |
let nominalTotalFrames = Int(round(duration * nominalFrameRate)) | |
let desiredFrameRate: CGFloat = 30.0 | |
// In order to convert from, say 30 FPS to 20, we'd need to remove 1/3 of the frames, this applies that math and decides which frames to remove/not process | |
let framesToRemove: [Int] = { | |
// Ensure the actual/nominal frame rate isn't already lower than the desired, in which case don't even worry about it | |
if desiredFrameRate < nominalFrameRate { | |
let percentageOfFramesToRemove = 1.0 - (desiredFrameRate / nominalFrameRate) | |
let totalFramesToRemove = Int(round(CGFloat(nominalTotalFrames) * percentageOfFramesToRemove)) | |
// We should remove a frame every `frameRemovalInterval` frames… | |
// Since we can't remove e.g.: the 3.7th frame, round that up to 4, and we'd remove the 4th frame, then the 7.4th -> 7th, etc. | |
let frameRemovalInterval = CGFloat(nominalTotalFrames) / CGFloat(totalFramesToRemove) | |
var framesToRemove: [Int] = [] | |
var sum: CGFloat = 0.0 | |
while sum <= CGFloat(nominalTotalFrames) { | |
sum += frameRemovalInterval | |
let roundedFrameToRemove = Int(round(sum)) | |
framesToRemove.append(roundedFrameToRemove) | |
} | |
return framesToRemove | |
} else { | |
return [] | |
} | |
}() | |
let totalFrames = nominalTotalFrames - framesToRemove.count | |
let outputSettings: [String: Any] = [ | |
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32ARGB, | |
kCVPixelBufferWidthKey as String: resultingSize.width, | |
kCVPixelBufferHeightKey as String: resultingSize.height | |
] | |
let readerOutput = AVAssetReaderTrackOutput(track: videoTrack, outputSettings: outputSettings) | |
reader.add(readerOutput) | |
reader.startReading() | |
var sample: CMSampleBuffer? = readerOutput.copyNextSampleBuffer() | |
let delayBetweenFrames: CGFloat = 1.0 / min(desiredFrameRate, nominalFrameRate) | |
print("Nominal total frames: \(nominalTotalFrames), totalFramesUsed: \(totalFrames), totalFramesToRemove: \(framesToRemove.count), nominalFrameRate: \(nominalFrameRate), delayBetweenFrames: \(delayBetweenFrames)") | |
let fileProperties: [String: Any] = [ | |
kCGImagePropertyGIFDictionary as String: [ | |
kCGImagePropertyGIFLoopCount as String: 0 | |
] | |
] | |
let frameProperties: [String: Any] = [ | |
kCGImagePropertyGIFDictionary as String: [ | |
kCGImagePropertyGIFDelayTime: delayBetweenFrames | |
] | |
] | |
let resultingFilename = String(format: "%@_%@", ProcessInfo.processInfo.globallyUniqueString, "html5gif.gif") | |
let resultingFileURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(resultingFilename) | |
guard let destination = CGImageDestinationCreateWithURL(resultingFileURL as CFURL, kUTTypeGIF, totalFrames, nil) else { | |
return | |
} | |
CGImageDestinationSetProperties(destination, fileProperties as CFDictionary) | |
let operationQueue = OperationQueue() | |
operationQueue.maxConcurrentOperationCount = 1 | |
var framesCompleted = 0 | |
var currentFrameIndex = 0 | |
while (sample != nil) { | |
currentFrameIndex += 1 | |
if framesToRemove.contains(currentFrameIndex) { | |
sample = readerOutput.copyNextSampleBuffer() | |
continue | |
} | |
if let newSample = sample { | |
// Create it as an optional and manually nil it out every time it's finished otherwise weird Swift bug where memory will balloon enormously (see https://twitter.com/ChristianSelig/status/1241572433095770114) | |
var cgImage: CGImage? = self.cgImageFromSampleBuffer(newSample) | |
operationQueue.addOperation { | |
framesCompleted += 1 | |
if let cgImage = cgImage { | |
CGImageDestinationAddImage(destination, cgImage, frameProperties as CFDictionary) | |
} | |
cgImage = nil | |
let progress = CGFloat(framesCompleted) / CGFloat(totalFrames) | |
// GIF progress is a little fudged so it works with downloading progress reports | |
let progressToReport = Int(progress * 100.0) | |
// print(progressToReport) | |
} | |
} | |
sample = readerOutput.copyNextSampleBuffer() | |
} | |
operationQueue.waitUntilAllOperationsAreFinished() | |
let didCreateGIF = CGImageDestinationFinalize(destination) | |
guard didCreateGIF else { | |
return | |
} | |
DispatchQueue.main.async { | |
PHPhotoLibrary.shared().performChanges({ | |
PHAssetCreationRequest.creationRequestForAssetFromImage(atFileURL: resultingFileURL) | |
}) { (saved, error) in | |
DispatchQueue.main.async { | |
if saved { | |
print("SAVED!") | |
} else { | |
} | |
} | |
} | |
} | |
private func cgImageFromSampleBuffer(_ buffer: CMSampleBuffer) -> CGImage? { | |
guard let pixelBuffer = CMSampleBufferGetImageBuffer(buffer) else { | |
return nil | |
} | |
CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly) | |
let baseAddress = CVPixelBufferGetBaseAddress(pixelBuffer) | |
let bytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer) | |
let width = CVPixelBufferGetWidth(pixelBuffer) | |
let height = CVPixelBufferGetHeight(pixelBuffer) | |
guard let context = CGContext(data: baseAddress, width: width, height: height, bitsPerComponent: 8, bytesPerRow: bytesPerRow, space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue) else { return nil } | |
let image = context.makeImage() | |
CVPixelBufferUnlockBaseAddress(pixelBuffer, .readOnly) | |
return image | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment