云端 Mac 上的 iOS 动画 GIF 导出验收:帧时长、循环与回读

云端 Mac 上的 iOS 动画 GIF 导出验收:帧时长、循环与回读

同一段动画,导出的 GIF 能打开,却出现首帧停顿、循环提前结束,或者总时长变短。先别改播放器。在云端 Mac 上生成固定样本,再用 ImageIO 读回文件,把“写错了”和“播放方式不同”拆开。下面的脚本只用系统框架与 Swift 工具链,不需要连接外部服务,也不涉及签名。

先把动画契约写清楚

取三帧纯色图,尺寸统一为 16×16,显示时间依次为 0.12、0.24、0.36 秒,循环字段设为 0,表示无限循环。单轮期望时长为 0.72 秒。这些数是测试输入,不是性能测量结果。

GIF 延迟以百分之一秒记录。业务若传入更细的时间值,先明确取整策略,再计算期望值;不要让编码器的隐式转换替你决定。第一轮选用能准确落在该单位上的数,避免把量化误差混进编码问题。

文件属性检查回答“写进去了什么”;播放器检查回答“用户看到了什么”。两层都通过,才算动画导出通过。

把尺寸、时间数组和循环策略放入版本控制。不要用随手录制的动画当唯一基线,否则素材变化后很难解释失败。

用 ImageIO 生成固定样本

在独立目录保存以下内容为 gif-check.swift。脚本生成三帧,完成写入后重新打开文件;任何检查失败都会以非零状态结束。文件名固定,所以并行任务应各用自己的工作目录。

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")

运行时先记录工具链,再执行验收:

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

这里保留校验和是为了标识本次文件,不要求不同系统版本生成完全相同的字节。颜色表、压缩选择可能改变,语义相同不等于二进制一致。

接入真实导出器,不止测试框架

上面的程序先证明当前工具链能够完成基础编码与解码。接入项目时,把生成目标文件的部分替换成业务导出器调用,保留同一套回读断言。否则测试通过的只是 ImageIO 示例,不是你的产品实现。

额外加入单帧、空帧数组和尺寸不一致三组输入。空输入应明确报错,不留下看似成功的文件;尺寸不一致时,要么提前拒绝,要么按约定画布归一化,不能静默裁切。

当前脚本只检查尺寸,没有检查颜色顺序。实际门禁还应把每帧绘制到统一 RGBA 位图,读取中心像素,确认红、绿、蓝顺序。不要直接比较解码缓冲区的原始字节:色彩空间、行跨度及像素布局都可能不同。

分开处理短延迟与透明帧

再增加一个独立样本,专门覆盖很短的延迟。读取时优先检查未钳制的时间属性,缺失时才回退到普通延迟属性,并记录实际读到了哪个字段。不要把属性缺失默认为零,那会让损坏样本伪装成快速动画。

透明帧也要另测。先画完整背景,再叠加局部移动物体,检查上一帧是否被正确清除。GIF 的透明度和调色板能力有限,不应拿半透明 PNG 的效果作为无条件预期。

播放器可能自行调整极短延迟,也可能采用不同的合成实现。在目标 iOS 播放组件上检查连续两轮,观察首尾衔接与残影;不要以远程桌面的录屏时长反推文件时间,传输与录屏本身会引入偏差。

让失败信息能指导修复

流水线至少保留工具链版本、输入契约、导出文件及逐帧属性记录。错误报告写出帧索引、期望值、实际值和属性来源,比一句“动画不一致”更有用。业务素材含敏感内容时,失败报告只用固定样本复现。

按三层定位:写入失败先查目标路径与结束写入结果;结构失败查帧数、尺寸和时间字典;结构通过但播放异常,再查循环解释、短帧限制与透明合成。不要先删缓存或换机器。

最终验收应同时满足:真实导出器退出成功、所有帧可解码、量化后时间符合契约、循环字段正确,以及目标播放器连续播放两轮没有异常。把失败样本留在回归集中,下次修改编码逻辑时先跑它们。

常见问题

GIF 每帧时长可以按任意毫秒值精确保存吗?

不可以。GIF 延迟以百分之一秒为单位保存,应先约定取整规则,并按量化后的期望值验收。播放器还可能限制过短的延迟,不能只凭播放观感判断编码结果。

回读检查通过后,还需要在 iOS 上播放验收吗?

需要。回读验证文件结构和属性;目标播放器上的测试验证循环策略、短帧处理及透明背景合成。两类检查不能互相替代。

给下一次构建分配一台云端 Mac

独享物理 Mac mini,非虚拟机。按天、周、月、季选择租期,配置与实际可用状态以控制台返回为准。