Validate iOS GIF exports: frame timing, loops and readback

Validate iOS GIF exports: frame timing, loops and readback

Validate iOS GIF exports: frame timing, loops and readback

An exported GIF may open successfully yet pause on the first frame, end its loop early, or run shorter than expected. Before changing the player, generate a fixed test fixture on a cloud Mac and read the file back with ImageIO. This separates encoding errors from differences in playback behavior. The script below uses only system frameworks and the Swift toolchain, with no external services or code signing required.

Define the animation contract first

Use three solid-color frames, each 16×16, with display durations of 0.12, 0.24, and 0.36 seconds. Set the loop field to 0 for infinite looping. The expected duration of one cycle is 0.72 seconds. These numbers are test inputs, not performance measurements.

GIF stores delays in hundredths of a second. If your application supplies finer-grained timing, define the rounding policy before calculating expected values. Do not let the encoder's implicit conversion make that decision for you. For the first test, choose values that are exactly representable in this unit so that quantization error does not obscure encoding problems.

File property checks tell you what was written; player checks tell you what the user saw. An animation export passes only when both layers pass.

Keep the dimensions, timing array, and looping policy under version control. Do not use an ad hoc recording as your only baseline: once the source material changes, failures become difficult to explain.

Create a fixed fixture with ImageIO

Save the following as gif-check.swift in a dedicated directory. The script generates three frames, finalizes the file, and opens it again. Any failed check causes it to exit with a nonzero status. Because the output filename is fixed, parallel jobs must each use their own working directory.

import Foundation
import CoreGraphics
import ImageIO
import UniformTypeIdentifiers

let url = URL(fileURLWithPath: "timing-fixture.gif")
let delays = [0.12, 0.24, 0.36]
let colors: [[CGFloat]] = [
    [1, 0, 0, 1],
    [0, 1, 0, 1],
    [0, 0, 1, 1]
]

func require(_ condition: Bool, _ message: String) {
    if !condition {
        FileHandle.standardError.write(Data((message + "\n").utf8))
        exit(1)
    }
}

guard let destination = CGImageDestinationCreateWithURL(
    url as CFURL, UTType.gif.identifier as CFString, delays.count, nil
) else {
    fatalError("Cannot create GIF destination")
}

CGImageDestinationSetProperties(destination, [
    kCGImagePropertyGIFDictionary: [
        kCGImagePropertyGIFLoopCount: 0
    ]
] as CFDictionary)

for (index, components) in colors.enumerated() {
    guard let context = CGContext(
        data: nil,
        width: 16,
        height: 16,
        bitsPerComponent: 8,
        bytesPerRow: 64,
        space: CGColorSpaceCreateDeviceRGB(),
        bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
    ) else {
        fatalError("Cannot create bitmap context")
    }
    context.setFillColor(
        CGColor(colorSpace: CGColorSpaceCreateDeviceRGB(),
                components: components)!
    )
    context.fill(CGRect(x: 0, y: 0, width: 16, height: 16))
    guard let image = context.makeImage() else {
        fatalError("Cannot create frame")
    }
    CGImageDestinationAddImage(destination, image, [
        kCGImagePropertyGIFDictionary: [
            kCGImagePropertyGIFDelayTime: delays[index]
        ]
    ] as CFDictionary)
}

require(CGImageDestinationFinalize(destination), "GIF finalization failed")

guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else {
    fatalError("Cannot reopen GIF")
}
require(CGImageSourceGetCount(source) == delays.count, "Frame count mismatch")

let global = CGImageSourceCopyProperties(source, nil) as NSDictionary?
let globalGIF = global?[kCGImagePropertyGIFDictionary] as? NSDictionary
let loops = globalGIF?[kCGImagePropertyGIFLoopCount] as? NSNumber
require(loops?.intValue == 0, "Loop count mismatch")

var total = 0.0
for index in delays.indices {
    guard let image = CGImageSourceCreateImageAtIndex(source, index, nil) else {
        fatalError("Cannot decode frame")
    }
    require(image.width == 16 && image.height == 16, "Frame size mismatch")
    let properties = CGImageSourceCopyPropertiesAtIndex(
        source, index, nil
    ) as NSDictionary?
    let gif = properties?[kCGImagePropertyGIFDictionary] as? NSDictionary
    let raw = gif?[kCGImagePropertyGIFUnclampedDelayTime] as? NSNumber
    let fallback = gif?[kCGImagePropertyGIFDelayTime] as? NSNumber
    guard let delay = (raw ?? fallback)?.doubleValue else {
        fatalError("Missing frame delay")
    }
    require(abs(delay - delays[index]) < 0.001, "Frame delay mismatch")
    total += delay
}
require(abs(total - 0.72) < 0.001, "Total duration mismatch")

Record the toolchain version before running the checks:

set -eu
xcrun swift --version > toolchain.txt
xcrun swift gif-check.swift
shasum -a 256 timing-fixture.gif > fixture.sha256

The checksum identifies the file produced by this run. It does not require different system versions to generate identical bytes. Color tables and compression choices may vary; equivalent content does not imply binary identity.

Test the real exporter, not just the framework

The program above first establishes that the current toolchain can handle basic encoding and decoding. When integrating it into your project, replace the file-generation portion with a call to your application's exporter, keeping the same readback assertions. Otherwise, a passing test validates only the ImageIO example, not your product's implementation.

Add three more input cases: a single frame, an empty frame array, and frames with mismatched dimensions. Empty input must produce an explicit error without leaving behind a file that appears to indicate success. For mismatched dimensions, either reject the input up front or normalize it to the agreed canvas. Never crop silently.

The current script checks dimensions but not color order. Your actual test gate should also render each frame into a consistent RGBA bitmap, read the center pixel, and verify the red, green, blue sequence. Do not compare the raw bytes of decoded buffers directly: color spaces, row strides, and pixel layouts can differ.

Test short delays and transparent frames separately

Add a separate fixture specifically for very short delays. When reading it back, check the unclamped timing property first and fall back to the standard delay property only if it is missing. Record which field supplied the value. Do not treat a missing property as zero; that can make a corrupted fixture look like a fast animation.

Transparent frames need their own test as well. Draw a complete background, then overlay a small moving object and check whether the previous frame is cleared correctly. GIF has limited transparency and palette capabilities, so the appearance of a semitransparent PNG is not an unconditional expectation.

Players may adjust very short delays themselves or use different compositing implementations. Check two consecutive cycles in the target iOS playback component, watching the transition from the last frame to the first and looking for ghosting. Do not infer the file's timing from a remote desktop screen recording: both transmission and recording introduce timing errors.

Make failure evidence actionable

At a minimum, retain the toolchain version, input contract, exported file, and per-frame property records in your pipeline. An error report that includes the frame index, expected value, actual value, and property source is more useful than “animation mismatch.” If production assets contain sensitive content, reproduce failures for reports using only the fixed fixture.

Diagnose failures at three levels. For write failures, first check the destination path and the finalization result. For structural failures, check frame count, dimensions, and timing dictionaries. If structure passes but playback is wrong, investigate loop interpretation, minimum frame delays, and transparency compositing. Do not start by clearing caches or switching machines.

Final acceptance requires all of the following: the real exporter exits successfully, every frame decodes, quantized timing matches the contract, the loop field is correct, and the target player completes two consecutive cycles without issues. Keep failing fixtures in the regression suite and run them first the next time you change the encoding logic.

Frequently asked questions

Can GIF store every frame delay with millisecond precision?

No. GIF stores delays in hundredths of a second. Define a rounding policy and check the quantized values. Players may also impose minimum delays.

Is playback testing still needed after readback passes?

Yes. Readback validates file structure and metadata. The target iOS player must separately validate looping, short delays and transparent-frame compositing.

Assign a cloud Mac to your next build

Dedicated physical Mac mini, not a virtual machine. Choose a daily, weekly, monthly, or quarterly rental. Check the console for current availability and configuration details.