Проверка экспорта GIF для iOS на облачном Mac

Проверка экспорта GIF для iOS на облачном Mac

Проверка экспорта GIF для iOS на облачном Mac

Экспортированный 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 хранит задержку в сотых долях секунды. Задайте правило округления и сравнивайте с округлённым значением. Проигрыватель может дополнительно ограничивать слишком короткие задержки.

Нужно ли проверять воспроизведение на iOS после чтения файла?

Да. Чтение проверяет структуру и свойства файла, а целевой проигрыватель — циклы, короткие задержки и композицию прозрачных кадров.

Выделенный облачный Mac для следующей сборки

Выделенный физический Mac mini, не виртуальная машина. Выберите срок аренды: день, неделя, месяц или квартал. Конфигурация и фактическая доступность указаны в консоли.