클라우드 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로 저장합니다. 스크립트는 세 프레임을 생성하고 파일 쓰기를 완료한 뒤 파일을 다시 엽니다. 검사 하나라도 실패하면 0이 아닌 종료 코드로 끝납니다. 파일명이 고정되어 있으므로 병렬 작업은 각각 별도의 작업 디렉터리를 사용해야 합니다.

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 비트맵에 그린 뒤 중심 픽셀을 읽어 빨강, 초록, 파랑 순서인지 확인하는 검사도 포함해야 합니다. 디코딩 버퍼의 원시 바이트를 직접 비교하지 마세요. 색 공간, 행 간격, 픽셀 배치가 서로 다를 수 있습니다.

짧은 지연 시간과 투명 프레임은 별도로 검증하기

매우 짧은 지연 시간을 검증하는 독립 샘플을 하나 더 추가합니다. 파일을 읽을 때는 하한 보정이 적용되지 않은 시간 속성을 먼저 확인하고, 해당 속성이 없을 때만 일반 지연 시간 속성으로 대체합니다. 실제로 어느 필드를 읽었는지도 기록하세요. 속성이 없다고 기본값을 0으로 처리하면 손상된 샘플이 빠르게 재생되는 애니메이션처럼 보일 수 있습니다.

투명 프레임도 따로 테스트해야 합니다. 먼저 전체 배경을 그린 다음 일부 영역에서 움직이는 물체를 겹쳐 그리고, 이전 프레임이 올바르게 지워지는지 확인합니다. GIF는 투명도와 팔레트 표현에 제약이 있으므로 반투명 PNG와 같은 결과를 항상 기대해서는 안 됩니다.

재생기는 매우 짧은 지연 시간을 자체적으로 조정하거나 서로 다른 합성 방식을 사용할 수 있습니다. 대상 iOS 재생 컴포넌트에서 두 차례 연속 재생해 처음과 끝이 이어지는 부분과 잔상을 확인하세요. 원격 데스크톱 화면의 녹화 시간을 근거로 파일의 재생 시간을 역산해서는 안 됩니다. 전송과 화면 녹화 자체가 오차를 만들기 때문입니다.

수정에 도움이 되는 실패 정보 남기기

파이프라인에는 최소한 도구 체인 버전, 입력 검증 기준, 내보낸 파일, 프레임별 속성 기록을 남겨야 합니다. 오류 보고서에 프레임 인덱스, 예상값, 실제값, 값을 읽은 속성을 명시하면 “애니메이션이 일치하지 않음”이라는 문구 하나보다 훨씬 유용합니다. 실제 애플리케이션의 원본에 민감한 내용이 포함되어 있다면, 실패 보고서는 고정 테스트 샘플로 재현한 결과만 사용하세요.

문제는 세 단계로 나눠 추적합니다. 쓰기에 실패했다면 먼저 대상 경로와 쓰기 완료 처리 결과를 확인합니다. 구조 검사가 실패했다면 프레임 수, 크기, 시간 속성 딕셔너리를 확인합니다. 구조 검사는 통과했지만 재생에 이상이 있다면 반복 필드의 해석, 짧은 프레임 시간에 대한 제한, 투명 프레임 합성을 살펴봅니다. 캐시를 지우거나 기기를 바꾸는 것부터 시작하지 마세요.

최종 검증에서는 실제 내보내기 구현이 성공으로 종료되고, 모든 프레임을 디코딩할 수 있으며, 양자화된 시간이 검증 기준에 맞고, 반복 필드가 올바르며, 대상 재생기에서 두 차례 연속 재생해도 이상이 없어야 합니다. 실패한 샘플은 회귀 테스트에 남겨 두고, 다음에 인코딩 로직을 수정할 때 먼저 실행하세요.

자주 묻는 질문

GIF는 모든 프레임 시간을 밀리초 단위로 정확히 저장하나요?

아닙니다. GIF 지연 시간은 100분의 1초 단위로 저장됩니다. 반올림 규칙을 정하고 변환된 값을 검증해야 합니다. 재생기가 너무 짧은 지연 시간을 별도로 제한할 수도 있습니다.

다시 읽기 검증을 통과해도 iOS 재생 테스트가 필요한가요?

필요합니다. 다시 읽기는 파일 구조와 속성을 검증합니다. 반복 처리, 짧은 지연 시간, 투명 프레임 합성은 대상 iOS 재생기에서 따로 확인해야 합니다.

다음 빌드를 위한 클라우드 Mac을 준비하세요

가상 머신이 아닌 전용 물리 Mac mini입니다. 일·주·월·분기 단위로 대여 기간을 선택할 수 있으며, 실제 이용 가능 여부와 구성은 콘솔에서 확인하세요.