How are animated image frames and delays stored, and what happens to a zero or tiny frame delay?

18.7.2 · L3 · Interview

Predict first

What do the frame properties report?

import Foundation
import ImageIO
import CoreGraphics
import UniformTypeIdentifiers

func frame(_ shade: CGFloat) -> CGImage {
    let c = CGContext(data: nil, width: 8, height: 8, bitsPerComponent: 8, bytesPerRow: 0, space: CGColorSpace(name: CGColorSpace.sRGB)!,
                      bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)!
    c.setFillColor(red: shade, green: shade, blue: shade, alpha: 1)
    c.fill(CGRect(x: 0, y: 0, width: 8, height: 8))
    return c.makeImage()!
}
let delays: [Double] = [0.1, 0.5, 0.0, 0.03]
let data = NSMutableData()
let dest = CGImageDestinationCreateWithData(data, UTType.gif.identifier as CFString, delays.count, nil)!
CGImageDestinationSetProperties(dest, [kCGImagePropertyGIFDictionary: [kCGImagePropertyGIFLoopCount: 0]] as CFDictionary)
for (i, d) in delays.enumerated() {
    CGImageDestinationAddImage(dest, frame(CGFloat(i) / 3), [kCGImagePropertyGIFDictionary: [kCGImagePropertyGIFDelayTime: d]] as CFDictionary)
}
CGImageDestinationFinalize(dest)

let source = CGImageSourceCreateWithData(data, nil)!
print("type:", CGImageSourceGetType(source)!, "| frames:", CGImageSourceGetCount(source))
let container = CGImageSourceCopyProperties(source, nil) as! [CFString: Any]
print("loop count (0 = forever):", (container[kCGImagePropertyGIFDictionary] as! [CFString: Any])[kCGImagePropertyGIFLoopCount]!)
for i in 0..<CGImageSourceGetCount(source) {
    let p = CGImageSourceCopyPropertiesAtIndex(source, i, nil) as! [CFString: Any]
    let g = p[kCGImagePropertyGIFDictionary] as! [CFString: Any]
    print("frame \(i): delay \(g[kCGImagePropertyGIFDelayTime]!) | unclamped \(g[kCGImagePropertyGIFUnclampedDelayTime]!)")
}
  • 4 frames, loop 0, and the 0.0 delay reads back as 0.1 with unclamped 0
  • delays come back exactly as written
  • only 1 frame is stored

Mental model. A flip book where each page carries its own “hold for this long” note, and a blank note is read as a default rather than zero.

An animated GIF (or APNG, or HEICS) is one CGImageSource with several indexes: CGImageSourceGetCount gives the frame count, each frame is decoded with CreateImageAtIndex, and timing lives in per-frame properties, not in the pixels. For GIF the container has a loop count (0 means forever, as the check prints) and each frame has kCGImagePropertyGIFDelayTime and kCGImagePropertyGIFUnclampedDelayTime. The check wrote delays 0.1, 0.5, 0.0 and 0.03: they came back as 0.1, 0.5, 0.1 (clamped) with unclamped 0, and 0.03. ImageIO substitutes 0.1 s for a delay of 0 (browsers do the same), so an image that asks for “as fast as possible” plays at 10 fps unless you read the unclamped value and decide yourself.

For playback, do not decode every frame up front: a 100-frame GIF at 600×400 is about 96 MB decoded (4 bytes per pixel, see the bitmap question). Decode lazily by index from a display link, keep a small window of frames, and accumulate delays to choose the frame instead of using a timer per frame. Honour the loop count, and pause when off screen. Not run: APNG and HEICS output, which differ in dictionary keys (kCGImagePropertyPNGDictionary).

Frame timing is metadata, and ImageIO clamps a zero delay.

Common traps

  • Trusting a delay of 0. It reads back as 0.1.
  • Decoding all frames at load. Memory grows with frames × pixels.

They might follow up with

  • How do you write one? CGImageDestination with the frame count up front, container properties set once, then per-frame properties.

Related questions

  • 18.7.1 Which ImageIO thumbnail options matter, and why does a thumbnail come out sideways or at the wrong size?
  • 18.7.3 How much memory does a bitmap use, why is bytesPerRow not width × 4, and what is premultiplied alpha?
  • 18.5.1 How do HEIF, JPEG, PNG and animated images differ, and what does ImageIO let you do with them?

← Which ImageIO thumbnail options matter, and why does a thumbnail come…   How much memory does a bitmap use, why is bytesPerRow not width × 4… →