使用 CGImageDestinationFinalize 创建大 GIF - 运行 内存不足

Creating a large GIF with CGImageDestinationFinalize - running out of memory

我正在尝试解决创建包含大量帧的 GIF 时的性能问题。例如,某些 GIF 可能包含 > 1200 帧。使用我当前的代码,我 运行 内存不足。我想弄清楚如何解决这个问题;这可以分批完成吗?我的第一个想法是是否可以将图像附加在一起,但我认为没有这种方法或 ImageIO 框架如何创建 GIF。如果有复数 CGImageDestinationAddImages 方法就好了,但没有,所以我不知道如何尝试解决这个问题。我感谢提供的任何帮助。对于冗长的代码提前抱歉,但我觉得有必要展示 GIF 的逐步创建。

同意table我可以制作视频文件而不是 GIF,只要视频中可能存在不同的 GIF 帧延迟并且录制时间不超过所有帧延迟的总和每帧动画。

注意:跳转到下面的最新更新标题以跳过背景故事。

更新 1 - 6: 使用 GCD 修复了线程锁,但内存问题仍然存在。 100% CPU 利用率在这里不是问题,因为我在执行工作时显示 UIActivityIndicatorView。使用 drawViewHierarchyInRect 方法可能比 renderInContext 方法更 efficient/speedy,但是我发现你不能在后台线程上使用 drawViewHierarchyInRect 方法和 afterScreenUpdates 属性设置为YES;它锁定了线程。

一定有什么办法可以批量写出GIF。我相信我已经将内存问题缩小到: CGImageDestinationFinalize 这种方法对于制作具有大量帧的图像来说似乎效率很低,因为所有内容都必须在内存中才能写出整个图像。我已经确认了这一点,因为我在抓取渲染的 containerView 图层图像并调用 CGImageDestinationAddImage 时使用了很少的内存。 在我调用 CGImageDestinationFinalize 的那一刻,内存表立即飙升;有时根据帧数最多可达 2GB。制作 ~20-1000KB GIF 所需的内存量似乎很疯狂。

更新 2: 我发现有一种方法可能会带来一些希望。它是:

CGImageDestinationCopyImageSource(CGImageDestinationRef idst, 
CGImageSourceRef isrc, CFDictionaryRef options,CFErrorRef* err) 

我的新想法是,对于每 10 帧或其他任意帧数,我会将它们写入一个目标,然后在下一个循环中,之前完成的具有 10 帧的目标现在将成为我的新源。但是有一个问题;阅读它声明的文档:

Losslessly copies the contents of the image source, 'isrc', to the * destination, 'idst'. 
The image data will not be modified. No other images should be added to the image destination. 
* CGImageDestinationFinalize() should not be called afterward -
* the result is saved to the destination when this function returns.

这让我觉得我的想法行不通,但唉,我试过了。继续更新 3.

更新 3: 我一直在使用下面更新的代码尝试 CGImageDestinationCopyImageSource 方法,但是我总是返回只有一帧的图像;这是因为最有可能是因为上面更新 2 中所述的文档。还有另一种方法可以尝试:CGImageSourceCreateIncremental 但我怀疑那是我需要的。

我似乎需要某种方式 writing/appending 将 GIF 帧增量地写入磁盘,这样我就可以清除内存中的每个新块。也许 CGImageDestinationCreateWithDataConsumer 具有适当的回调以增量保存数据是理想的?

更新 4: 我开始尝试 CGImageDestinationCreateWithDataConsumer 方法,看看我是否可以使用 NSFileHandle 设法写出字节,但问题是调用 CGImageDestinationFinalize 发送所有字节与以前一样的一次拍摄 - 我 运行 内存不足。我真的需要帮助来解决这个问题,并将提供大量赏金。

更新 5: 我已经发布了一大笔赏金。我希望看到一些没有第 3 方库或框架的出色解决方案来将原始 NSData GIF 字节相互附加并使用 NSFileHandle 将其增量写入磁盘 - 本质上是手动创建 GIF。或者,如果您认为可以像我尝试过的那样使用 ImageIO 找到解决方案,那也太棒了。 Swizzling、子类化等

更新 6: 我一直在研究如何在最低级别制作 GIF,并且我编写了一个小测试,它与我在赏金帮助下的目标一致。我需要获取渲染的 UIImage,从中获取字节,使用 LZW 压缩它并附加字节以及确定全局颜色 table 等其他一些工作。信息来源:http://giflib.sourceforge.net/whatsinagif/bits_and_bytes.html .

最新更新:

我花了整整一周的时间从各个角度对此进行研究,以了解在限制(例如最大 256 色)的基础上构建质量不错的 GIF 到底发生了什么。我相信并假设 ImageIO 正在做的是在引擎盖下创建一个单一的位图上下文,将所有图像帧合并为一个,并且正在对该位图执行颜色量化以生成单一的全局颜色 table在 GIF 中使用。在 ImageIO 制作的一些成功的 GIF 上使用十六进制编辑器确认它们具有全局颜色 table 并且永远不会有本地颜色,除非您自己为每一帧设置它。对这个巨大的位图执行颜色量化以构建调色板(再次假设,但坚信)。

我有一个奇怪而疯狂的想法:我的应用程序中的帧图像每帧只能有一种颜色不同,更好的是,我知道什么我的应用程序使用的一小部分颜色。 first/background 框架是一个包含我无法控制的颜色的框架(用户提供的内容,例如照片)所以我想的是我将快照这个视图,然后快照另一个具有我已知颜色的视图应用程序处理并使它成为我可以传递到普通 ImaegIO GIF 制作例程的单个位图上下文。有什么好处?好吧,这通过将两个图像合并为一个图像将它从大约 1200 帧减少到一个。 ImageIO 然后将在更小的位图上执行它的操作,并用一帧写出单个 GIF。

现在我该怎么做才能构建实际的 1200 帧 GIF?我想我可以采用单帧 GIF 并很好地提取颜色 table 字节,因为它们位于两个 GIF 协议块之间。我仍然需要手动构建 GIF,但现在我不必计算调色板。我将窃取调色板 ImageIO 认为是最好的并将其用于我的字节缓冲区。在赏金的帮助下,我仍然需要一个 LZW 压缩器实现,但这应该比颜色量化容易得多,颜色量化可能会非常慢。 LZW 也可能很慢,所以我不确定它是否值得;不知道 LZW 将如何按顺序执行 ~1200 帧。

你有什么想法?

@property (nonatomic, strong) NSFileHandle *outputHandle;    

- (void)makeGIF
{
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0),^
    {
        NSString *filePath = @"/Users/Test/Desktop/Test.gif";

        [[NSFileManager defaultManager] createFileAtPath:filePath contents:nil attributes:nil];

        self.outputHandle = [NSFileHandle fileHandleForWritingAtPath:filePath];

        NSMutableData *openingData = [[NSMutableData alloc]init];

        // GIF89a header

        const uint8_t gif89aHeader [] = { 0x47, 0x49, 0x46, 0x38, 0x39, 0x61 };

        [openingData appendBytes:gif89aHeader length:sizeof(gif89aHeader)];


        const uint8_t screenDescriptor [] = { 0x0A, 0x00, 0x0A, 0x00, 0x91, 0x00, 0x00 };

        [openingData appendBytes:screenDescriptor length:sizeof(screenDescriptor)];


        // Global color table

        const uint8_t globalColorTable [] = { 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00 };

        [openingData appendBytes:globalColorTable length:sizeof(globalColorTable)];


        // 'Netscape 2.0' - Loop forever

        const uint8_t applicationExtension [] = { 0x21, 0xFF, 0x0B, 0x4E, 0x45, 0x54, 0x53, 0x43, 0x41, 0x50, 0x45, 0x32, 0x2E, 0x30, 0x03, 0x01, 0x00, 0x00, 0x00 };

        [openingData appendBytes:applicationExtension length:sizeof(applicationExtension)];

        [self.outputHandle writeData:openingData];

        for (NSUInteger i = 0; i < 1200; i++)
        {
            const uint8_t graphicsControl [] = { 0x21, 0xF9, 0x04, 0x04, 0x32, 0x00, 0x00, 0x00 };

            NSMutableData *imageData = [[NSMutableData alloc]init];

            [imageData appendBytes:graphicsControl length:sizeof(graphicsControl)];


            const uint8_t imageDescriptor [] = { 0x2C, 0x00, 0x00, 0x00, 0x00, 0x0A, 0x00, 0x0A, 0x00, 0x00 };

            [imageData appendBytes:imageDescriptor length:sizeof(imageDescriptor)];


            const uint8_t image [] = { 0x02, 0x16, 0x8C, 0x2D, 0x99, 0x87, 0x2A, 0x1C, 0xDC, 0x33, 0xA0, 0x02, 0x75, 0xEC, 0x95, 0xFA, 0xA8, 0xDE, 0x60, 0x8C, 0x04, 0x91, 0x4C, 0x01, 0x00 };

            [imageData appendBytes:image length:sizeof(image)];


            [self.outputHandle writeData:imageData];
        }


        NSMutableData *closingData = [[NSMutableData alloc]init];

        const uint8_t appSignature [] = { 0x21, 0xFE, 0x02, 0x48, 0x69, 0x00 };

        [closingData appendBytes:appSignature length:sizeof(appSignature)];


        const uint8_t trailer [] = { 0x3B };

        [closingData appendBytes:trailer length:sizeof(trailer)];


        [self.outputHandle writeData:closingData];

        [self.outputHandle closeFile];

        self.outputHandle = nil;

        dispatch_async(dispatch_get_main_queue(),^
        {
           // Get back to main thread and do something with the GIF
        });
    });
}

- (UIImage *)getImage
{
    // Read question's 'Update 1' to see why I'm not using the
    // drawViewHierarchyInRect method
    UIGraphicsBeginImageContextWithOptions(self.containerView.bounds.size, NO, 1.0);
    [self.containerView.layer renderInContext:UIGraphicsGetCurrentContext()];
    UIImage *snapShot = UIGraphicsGetImageFromCurrentImageContext();
    UIGraphicsEndImageContext();

    // Shaves exported gif size considerably
    NSData *data = UIImageJPEGRepresentation(snapShot, 1.0);

    return [UIImage imageWithData:data];
}

您可以使用 AVFoundation 用您的图像编写视频。我已经上传了一个完整的工作测试项目 to this github repository。当您 运行 在模拟器中测试项目时,它会向调试控制台打印一个文件路径。在您的视频播放器中打开该路径以检查输出。

我将在这个答案中介绍代码的重要部分。

首先创建一个 AVAssetWriter。我会给它 AVFileTypeAppleM4V 文件类型,这样视频就可以在 iOS 设备上播放。

AVAssetWriter *writer = [AVAssetWriter assetWriterWithURL:self.url fileType:AVFileTypeAppleM4V error:&error];

使用视频参数设置输出设置字典:

- (NSDictionary *)videoOutputSettings {
    return @{
             AVVideoCodecKey: AVVideoCodecH264,
             AVVideoWidthKey: @((size_t)size.width),
             AVVideoHeightKey: @((size_t)size.height),
             AVVideoCompressionPropertiesKey: @{
                     AVVideoProfileLevelKey: AVVideoProfileLevelH264Baseline31,
                     AVVideoAverageBitRateKey: @(1200000) }};
}

您可以调整比特率来控制视频文件的大小。我在这里非常保守地选择了编解码器配置文件 (it supports some pretty old devices)。您可能想选择稍后的配置文件。

然后创建媒体类型 AVMediaTypeVideo 和输出设置的 AVAssetWriterInput

NSDictionary *outputSettings = [self videoOutputSettings];
AVAssetWriterInput *input = [AVAssetWriterInput assetWriterInputWithMediaType:AVMediaTypeVideo outputSettings:outputSettings];

设置像素缓冲区属性字典:

- (NSDictionary *)pixelBufferAttributes {
    return @{
             fromCF kCVPixelBufferPixelFormatTypeKey: @(kCVPixelFormatType_32BGRA),
             fromCF kCVPixelBufferCGBitmapContextCompatibilityKey: @YES };
}

您不必在此处指定像素缓冲区尺寸; AVFoundation 将从输入的输出设置中获取它们。我在这里使用的属性(我相信)最适合使用 Core Graphics 进行绘图。

接下来,使用像素缓冲区设置为您的输入创建一个 AVAssetWriterInputPixelBufferAdaptor

AVAssetWriterInputPixelBufferAdaptor *adaptor = [AVAssetWriterInputPixelBufferAdaptor
    assetWriterInputPixelBufferAdaptorWithAssetWriterInput:input
    sourcePixelBufferAttributes:[self pixelBufferAttributes]];

将输入添加到编写器并告诉编写器开始:

[writer addInput:input];
[writer startWriting];
[writer startSessionAtSourceTime:kCMTimeZero];

接下来我们将告诉输入如何获取视频帧。是的,我们可以在告诉作者开始写作后执行此操作:

    [input requestMediaDataWhenReadyOnQueue:adaptorQueue usingBlock:^{

这个块将完成我们需要用 AVFoundation 做的所有其他事情。输入每次准备好接受更多数据时都会调用它。它可能能够在一次调用中接受多个帧,所以只要准备就绪,我们就会循环:

        while (input.readyForMoreMediaData && self.frameGenerator.hasNextFrame) {

我正在使用 self.frameGenerator 实际绘制框架。我稍后会展示该代码。 frameGenerator 决定视频何时结束(通过 return 来自 hasNextFrame 的 NO)。它还知道每一帧应该何时出现在屏幕上:

            CMTime time = self.frameGenerator.nextFramePresentationTime;

要真正绘制帧,我们需要从适配器获取像素缓冲区:

            CVPixelBufferRef buffer = 0;
            CVPixelBufferPoolRef pool = adaptor.pixelBufferPool;
            CVReturn code = CVPixelBufferPoolCreatePixelBuffer(0, pool, &buffer);
            if (code != kCVReturnSuccess) {
                errorBlock([self errorWithFormat:@"could not create pixel buffer; CoreVideo error code %ld", (long)code]);
                [input markAsFinished];
                [writer cancelWriting];
                return;
            } else {

如果我们无法获得像素缓冲区,我们会发出错误信号并中止所有操作。如果我们确实得到了一个像素缓冲区,我们需要在它周围包装一个位图上下文并要求 frameGenerator 在上下文中绘制下一帧:

                CVPixelBufferLockBaseAddress(buffer, 0); {
                    CGColorSpaceRef rgb = CGColorSpaceCreateDeviceRGB(); {
                        CGContextRef gc = CGBitmapContextCreate(CVPixelBufferGetBaseAddress(buffer), CVPixelBufferGetWidth(buffer), CVPixelBufferGetHeight(buffer), 8, CVPixelBufferGetBytesPerRow(buffer), rgb, kCGBitmapByteOrder32Little | kCGImageAlphaPremultipliedFirst); {
                            [self.frameGenerator drawNextFrameInContext:gc];
                        } CGContextRelease(gc);
                    } CGColorSpaceRelease(rgb);

现在我们可以将缓冲区附加到视频。适配器这样做:

                    [adaptor appendPixelBuffer:buffer withPresentationTime:time];
                } CVPixelBufferUnlockBaseAddress(buffer, 0);
            } CVPixelBufferRelease(buffer);
        }

上面的循环通过适配器推送帧,直到输入表明它已经足够,或者直到 frameGenerator 说它没有帧。如果 frameGenerator 有更多的帧,我们只是 return,输入会在准备好更多帧时再次调用我们:

        if (self.frameGenerator.hasNextFrame) {
            return;
        }

如果frameGenerator出帧,我们关闭输入:

        [input markAsFinished];

然后我们告诉作者完成。完成后它将调用完成处理程序:

        [writer finishWritingWithCompletionHandler:^{
            if (writer.status == AVAssetWriterStatusFailed) {
                errorBlock(writer.error);
            } else {
                dispatch_async(dispatch_get_main_queue(), doneBlock);
            }
        }];
    }];

相比之下,生成帧非常简单。这是生成器采用的协议:

@protocol DqdFrameGenerator <NSObject>

@required

// You should return the same size every time I ask for it.
@property (nonatomic, readonly) CGSize frameSize;

// I'll ask for frames in a loop. On each pass through the loop, I'll start by asking if you have any more frames:
@property (nonatomic, readonly) BOOL hasNextFrame;

// If you say NO, I'll stop asking and end the video.

// If you say YES, I'll ask for the presentation time of the next frame:
@property (nonatomic, readonly) CMTime nextFramePresentationTime;

// Then I'll ask you to draw the next frame into a bitmap graphics context:
- (void)drawNextFrameInContext:(CGContextRef)gc;

// Then I'll go back to the top of the loop.

@end

对于我的测试,我绘制了一个背景图像,并随着视频的进行慢慢地用纯红色覆盖它。

@implementation TestFrameGenerator {
    UIImage *baseImage;
    CMTime nextTime;
}

- (instancetype)init {
    if (self = [super init]) {
        baseImage = [UIImage imageNamed:@"baseImage.jpg"];
        _totalFramesCount = 100;
        nextTime = CMTimeMake(0, 30);
    }
    return self;
}

- (CGSize)frameSize {
    return baseImage.size;
}

- (BOOL)hasNextFrame {
    return self.framesEmittedCount < self.totalFramesCount;
}

- (CMTime)nextFramePresentationTime {
    return nextTime;
}

Core Graphics 将原点放在位图上下文的左下角,但我使用的是 UIImage,而 UIKit 喜欢将原点放在左上角。

- (void)drawNextFrameInContext:(CGContextRef)gc {
    CGContextTranslateCTM(gc, 0, baseImage.size.height);
    CGContextScaleCTM(gc, 1, -1);
    UIGraphicsPushContext(gc); {
        [baseImage drawAtPoint:CGPointZero];

        [[UIColor redColor] setFill];
        UIRectFill(CGRectMake(0, 0, baseImage.size.width, baseImage.size.height * self.framesEmittedCount / self.totalFramesCount));
    } UIGraphicsPopContext();

    ++_framesEmittedCount;

我调用了我的测试程序用来更新进度指示器的回调:

    if (self.frameGeneratedCallback != nil) {
        dispatch_async(dispatch_get_main_queue(), ^{
            self.frameGeneratedCallback();
        });
    }

最后,为了演示可变帧率,我以每秒 30 帧的速度发出前半帧,以每秒 15 帧的速度发出后半帧:

    if (self.framesEmittedCount < self.totalFramesCount / 2) {
        nextTime.value += 1;
    } else {
        nextTime.value += 2;
    }
}

@end

如果将kCGImagePropertyGIFHasGlobalColorMap设置为NO,则不会发生内存不足的情况。