クラウド Mac で検証する iOS の GIF 書き出し:表示時間とループ

クラウド Mac で検証する iOS の GIF 書き出し:表示時間とループ

クラウド Mac で検証する iOS の GIF 書き出し:表示時間とループ

同じアニメーションを書き出したはずなのに、GIF は開けても、最初のフレームで止まったり、ループが途中で終わったり、全体の再生時間が短くなったりすることがあります。まず再生側を変更するのではなく、クラウド Mac 上で固定のテストサンプルを生成し、ImageIO でファイルを読み直して、「書き込みの誤り」と「再生側の挙動の違い」を切り分けましょう。以下のスクリプトはシステムフレームワークと Swift ツールチェーンだけで動作し、外部サービスへの接続も署名も必要ありません。

まずアニメーションの仕様を明文化する

単色の画像を三フレーム用意し、サイズをすべて 16×16 にそろえます。表示時間は順に 0.12、0.24、0.36 秒、ループフィールドは無限ループを表す 0 に設定します。一周の想定時間は 0.72 秒です。これらの数値はテスト入力であり、性能測定の結果ではありません。

GIF の遅延時間は 100 分の 1 秒単位で記録されます。アプリ側からさらに細かい時間値を渡す場合は、先に丸め方を決め、そのルールに基づいて期待値を計算してください。エンコーダーの暗黙の変換に判断を任せてはいけません。最初のテストでは、この単位で正確に表せる値を選び、量子化誤差とエンコードの問題を混同しないようにします。

ファイル属性の検証で分かるのは「何が書き込まれたか」、プレーヤーの検証で分かるのは「ユーザーに何が見えたか」です。両方に合格して初めて、アニメーションの書き出しが正常だと判断できます。

サイズ、表示時間の配列、ループ方針はバージョン管理に含めてください。その場で適当に録画したアニメーションだけを基準にすると、素材が変わった後で失敗の原因を説明しにくくなります。

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

チェックサムを残すのは、今回生成したファイルを識別するためです。OS のバージョンが異なってもまったく同じバイト列が生成されることを求めているわけではありません。カラーテーブルや圧縮方式の選択は変わり得るため、意味的に同等でもバイナリが一致するとは限りません。

フレームワークだけでなく、実際のエクスポーターを検証する

上のプログラムでまず確認できるのは、現在のツールチェーンで基本的なエンコードとデコードができることです。プロジェクトに組み込む際は、対象ファイルを生成する部分をアプリのエクスポーター呼び出しに置き換え、読み直し後のアサーションはそのまま残してください。そうしなければ、テストに合格したのは ImageIO のサンプルであって、製品の実装ではありません。

さらに、単一フレーム、空のフレーム配列、サイズがそろっていないフレームの三種類の入力を追加します。空の入力では明確にエラーを返し、正常に生成されたように見えるファイルを残さないようにします。サイズが異なる場合は、事前に拒否するか、取り決めたキャンバスサイズに正規化してください。何も通知せずに切り抜いてはいけません。

現在のスクリプトはサイズだけを検証しており、色の順序は確認していません。実際の合否判定では、各フレームを共通の RGBA ビットマップ形式に描画し、中央のピクセルを読み取って、赤、緑、青の順序になっていることも確認します。デコードされたバッファーの生バイト列を直接比較しないでください。色空間、行ストライド、ピクセルレイアウトが異なる可能性があります。

短い遅延時間と透過フレームは分けて扱う

非常に短い遅延時間を検証するため、別のサンプルを追加します。読み取り時は、クランプされていない時間属性を優先し、その属性がない場合に限って通常の遅延時間属性にフォールバックします。また、実際にどちらのフィールドを読んだかも記録してください。属性がない場合にゼロとして扱うと、壊れたサンプルを高速なアニメーションと誤認してしまいます。

透過フレームも別途テストします。まず背景全体を描き、その上に画面の一部を移動するオブジェクトを重ね、前のフレームが正しく消去されるかを確認します。GIF の透過表現とパレットには制約があるため、半透明 PNG と同じ見た目になることを無条件に期待してはいけません。

プレーヤーによっては、極端に短い遅延時間を独自に調整したり、異なる合成処理を使ったりします。対象の iOS 再生コンポーネントで連続二周の再生を確認し、末尾から先頭へのつながりと残像を観察してください。リモートデスクトップの画面録画の長さからファイルの再生時間を逆算してはいけません。転送と録画自体が誤差を生むためです。

修正につながる失敗情報を残す

パイプラインでは、少なくともツールチェーンのバージョン、入力仕様、書き出したファイル、フレームごとの属性記録を保存します。エラーレポートには、フレームのインデックス、期待値、実測値、値を取得した属性を記載してください。「アニメーションが一致しない」という一言よりも、はるかに修正に役立ちます。業務用の素材に機密情報が含まれる場合は、失敗レポートには固定サンプルで再現した内容だけを載せます。

原因は三段階で切り分けます。書き込みに失敗した場合は、出力先のパスと書き込みの完了処理の結果を確認します。構造の検証に失敗した場合は、フレーム数、サイズ、時間情報の辞書を確認します。構造に問題がないのに再生がおかしい場合に、ループの解釈、短いフレーム時間の制限、透過合成を調べます。最初からキャッシュを削除したり、マシンを替えたりしないでください。

最終的な合格条件は、実際のエクスポーターが正常終了すること、すべてのフレームをデコードできること、量子化後の時間が仕様に合うこと、ループフィールドが正しいこと、そして対象プレーヤーで連続二周の再生に異常がないことです。失敗したサンプルは回帰テストに残し、次にエンコード処理を変更するときは、まずそれらを実行してください。

よくある質問

GIF の表示時間は任意のミリ秒値を正確に保存できますか?

できません。GIF の遅延時間は百分の一秒単位です。丸め方を決め、その結果を期待値として検証します。再生側が短い遅延を補正する場合もあります。

再読み込みの検証後も iOS での再生確認は必要ですか?

必要です。再読み込みではファイルの構造と属性を確認します。ループ、短い遅延、透明フレームの合成は対象の再生実装で別途確認してください。

次のビルドに、クラウド Mac を

専有の物理 Mac mini をご利用いただけます。仮想マシンではありません。日・週・月・四半期から利用期間を選べます。構成と実際の空き状況は、コンソールの表示をご確認ください。