Introduction
To turn an array of UIImage frames into a movie on iOS, the usual solution is AVAssetWriter plus one pixel buffer per frame. The workflow is straightforward once the pieces are clear: choose one output size, convert each image into a CVPixelBuffer, assign presentation times, and finish the writer cleanly.
Build the Writer First
AVAssetWriter creates the output file, and AVAssetWriterInput describes the encoded video stream.
1import AVFoundation
2import UIKit
3
4func makeWriter(outputURL: URL, size: CGSize) throws -> (AVAssetWriter, AVAssetWriterInput) {
5 let writer = try AVAssetWriter(outputURL: outputURL, fileType: .mp4)
6
7 let settings: [String: Any] = [
8 AVVideoCodecKey: AVVideoCodecType.h264,
9 AVVideoWidthKey: Int(size.width),
10 AVVideoHeightKey: Int(size.height)
11 ]
12
13 let input = AVAssetWriterInput(mediaType: .video, outputSettings: settings)
14 input.expectsMediaDataInRealTime = false
15
16 guard writer.canAdd(input) else {
17 throw NSError(domain: "MovieExport", code: 1)
18 }
19
20 writer.add(input)
21 return (writer, input)
22}
Every frame in the movie must match the same output dimensions. If your images come from different sources, normalize them before or during conversion.
Convert UIImage to CVPixelBuffer
The writer cannot append UIImage objects directly. It needs a pixel buffer in a consistent format.
1import CoreVideo
2import UIKit
3
4extension UIImage {
5 func pixelBuffer(size: CGSize) -> CVPixelBuffer? {
6 var buffer: CVPixelBuffer?
7 let attrs = [
8 kCVPixelBufferCGImageCompatibilityKey: true,
9 kCVPixelBufferCGBitmapContextCompatibilityKey: true
10 ] as CFDictionary
11
12 let status = CVPixelBufferCreate(
13 kCFAllocatorDefault,
14 Int(size.width),
15 Int(size.height),
16 kCVPixelFormatType_32ARGB,
17 attrs,
18 &buffer
19 )
20
21 guard status == kCVReturnSuccess, let pixelBuffer = buffer else {
22 return nil
23 }
24
25 CVPixelBufferLockBaseAddress(pixelBuffer, [])
26 defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, []) }
27
28 guard let context = CGContext(
29 data: CVPixelBufferGetBaseAddress(pixelBuffer),
30 width: Int(size.width),
31 height: Int(size.height),
32 bitsPerComponent: 8,
33 bytesPerRow: CVPixelBufferGetBytesPerRow(pixelBuffer),
34 space: CGColorSpaceCreateDeviceRGB(),
35 bitmapInfo: CGImageAlphaInfo.noneSkipFirst.rawValue
36 ) else {
37 return nil
38 }
39
40 UIGraphicsPushContext(context)
41 draw(in: CGRect(origin: .zero, size: size))
42 UIGraphicsPopContext()
43
44 return pixelBuffer
45 }
46}
This redraws each image into a stable canvas, which is exactly what the encoder expects.
Append Frames With Predictable Timing
Once you have a writer and a pixel-buffer adaptor, append frames in order using one presentation time per frame.
1import AVFoundation
2import UIKit
3
4func exportMovie(
5 images: [UIImage],
6 outputURL: URL,
7 fps: Int = 30,
8 size: CGSize,
9 completion: @escaping (Result<URL, Error>) -> Void
10) {
11 do {
12 if FileManager.default.fileExists(atPath: outputURL.path) {
13 try FileManager.default.removeItem(at: outputURL)
14 }
15
16 let (writer, input) = try makeWriter(outputURL: outputURL, size: size)
17
18 let attrs: [String: Any] = [
19 kCVPixelBufferPixelFormatTypeKey as String: Int(kCVPixelFormatType_32ARGB),
20 kCVPixelBufferWidthKey as String: Int(size.width),
21 kCVPixelBufferHeightKey as String: Int(size.height)
22 ]
23
24 let adaptor = AVAssetWriterInputPixelBufferAdaptor(
25 assetWriterInput: input,
26 sourcePixelBufferAttributes: attrs
27 )
28
29 writer.startWriting()
30 writer.startSession(atSourceTime: .zero)
31
32 let frameDuration = CMTime(value: 1, timescale: CMTimeScale(fps))
33
34 for (index, image) in images.enumerated() {
35 while !input.isReadyForMoreMediaData {
36 Thread.sleep(forTimeInterval: 0.01)
37 }
38
39 guard let pixelBuffer = image.pixelBuffer(size: size) else {
40 throw NSError(domain: "MovieExport", code: 2)
41 }
42
43 let presentationTime = CMTimeMultiply(frameDuration, multiplier: Int32(index))
44 adaptor.append(pixelBuffer, withPresentationTime: presentationTime)
45 }
46
47 input.markAsFinished()
48 writer.finishWriting {
49 if let error = writer.error {
50 completion(.failure(error))
51 } else {
52 completion(.success(outputURL))
53 }
54 }
55 } catch {
56 completion(.failure(error))
57 }
58}
This takes you from an image array to an .mp4 file with deterministic frame timing.
Decide the Frame Rate Up Front
The chosen fps controls how long each still image stays on screen. At 30 fps, each image lasts one thirtieth of a second. If you want a slower slideshow effect, either lower the frame rate or append the same frame multiple times.
That is a design choice, not an export bug. People often think the movie is wrong when the real issue is that the frame timing does not match the intended playback speed.
Common Pitfalls
The most common mistake is allowing frames to have inconsistent sizes. Video encoders expect one stable width and height for the whole stream.
Another mistake is forgetting to remove an existing file at the output path. AVAssetWriter will not silently overwrite it.
Developers also sometimes skip input.isReadyForMoreMediaData and then wonder why frames are dropped or append calls fail.
Finally, finishWriting completes asynchronously. If you try to use the file before the completion handler runs, you may read an incomplete movie.
Summary
Exporting UIImage frames to video usually means AVAssetWriter plus pixel buffers.
Convert every image to a CVPixelBuffer using one fixed output size.
Append frames with explicit presentation times based on your target frame rate.
Remove any existing output file before writing.
Treat finishWriting as asynchronous and wait for completion before using the movie.