使用CGImageDestinationFinalize创建一个大的GIF时,内存不足

27

我正在尝试解决创建包含大量帧的GIF时的性能问题。例如,一些GIF可能包含> 1200帧。使用我的当前代码会耗尽内存。我正试图想出如何解决这个问题;是否可以分批处理?我的第一个想法是将图像拼接在一起,但我不认为有一种方法可以实现或者GIF是如何由ImageIO框架创建的。如果有复数CGImageDestinationAddImages方法就好了,但是没有,所以我迷失在如何尝试解决这个问题的路上。感谢提供的任何帮助。抱歉事先写了这么长的代码,但我觉得有必要显示逐步创建GIF。

只要视频中的不同GIF帧延迟在视频中也是可能的,并且录制的时间不会超过每帧动画的总和,那么用视频文件代替GIF是可以接受的。

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

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

必须有一种批量写入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.

这让我认为我的想法行不通,但我还是试了。继续进行第三次更新。

第三次更新:

我已经尝试了以下更新后的代码中的CGImageDestinationCopyImageSource方法,但我一直只得到一个帧的图像;这很可能是由于上述第2个更新中的文档所述。也许还有另一种方法可以尝试:使用CGImageSourceCreateIncremental,但我怀疑那不是我需要的。

看起来我需要一种将GIF帧逐步写入/附加到磁盘的方式,以便我可以将每个新块清除出内存。也许使用适当的回调函数创建CGImageDestinationCreateWithDataConsumer以逐步保存数据会更理想?

第4次更新:

我开始尝试使用CGImageDestinationCreateWithDataConsumer方法,看看是否可以使用NSFileHandle将字节按它们进入的顺序写出,但问题仍然是调用CGImageDestinationFinalize会一次性发送所有字节,这与之前相同 - 我的内存不足。我真的需要帮助解决这个问题,并将提供大量赏金。

第5次更新:

我发布了大量赏金。我希望看到一些辉煌的解决方案,而无需第三方库或框架来将原始的NSData GIF字节附加到彼此并使用NSFileHandle逐步写出它 - 基本上是手动创建GIF。或者,如果您认为可以使用像我尝试过的ImageIO找到的解决方案那将是令人惊奇的。交换、子类化等。

第6次更新:

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

最新更新:

我已经花了整个星期从各个角度研究这个问题,以了解基于限制(例如最多256种颜色)构建良好质量的GIF所需的所有步骤。我认为并假设ImageIO正在在幕后创建一个单个位图上下文,将所有图像帧合并为一个,并对该位图执行颜色量化以生成用于GIF的单个全局颜色表。使用十六进制编辑器查看一些成功从ImageIO创建的GIF确认它们具有全局颜色表,并且除非您自己为每个帧设置它,否则永远不会有本地颜色表。我相信这个巨大的位图进行颜色量化来构建调色板(再次假设,但强烈相信)。

我有这么一个奇怪而疯狂的想法:我的应用程序中的框架图像每个帧只能相差一个颜色,更好的是,我知道我的应用程序使用了哪些小型颜色集。第一/背景帧是包含用户提供内容(如照片)的颜色,因此我的想法是:我将拍摄此视图的快照,然后再拍摄另一个具有我应用程序处理的已知颜色的视图,并将其作为单个位图上下文,可以传递到 normalImaegIO GIF 制作例程中。优点是什么?这将把 ~1200 帧缩减为一个图像,通过将两个图像合并为一个图像。 ImageIO 然后会在更小的位图上执行其操作,并写出一个带有一个框架的单个 GIF。

现在我该如何制作实际的 1200 帧 GIF?我正在考虑获取那个单帧 GIF 并提取颜色表字节,因为它们跨越两个 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];
}

3
关键词:@autoreleasepool - http://stackoverflow.com/search?q=[objective-c]%40autoreleasepool这是一个在Objective-C中使用的自动释放池关键字。它用于管理内存,避免内存泄漏和提高性能。当一个对象被创建时,它被添加到自动释放池中。当自动释放池被销毁时,其中的所有对象都会被释放。这个关键字通常用在循环中或者在需要分配大量临时对象的代码块中,以便更有效地管理内存。 - user3577225
2
你尝试过以下方法吗?对于每个帧:将帧图像写入单独的图像文件,从该文件创建CGImageSource,将来自该源的图像添加到目标中,释放该源。在完成目标后,删除临时图像文件。这样做的想法是图像由文件支持,因此它们不必一直存在于内存中。它们可以根据需要清除并重新加载。 - Ken Thomases
我同意Ken Thomases的观点。如果你将图像写入磁盘并使用某些API进行加载(UIImage可以实现,但对于Core Graphics我不太确定。CGImageSource听起来是正确的),在低内存情况下它将自动从RAM中删除,并在以后需要该图像时从磁盘重新加载。 - Abhi Beckert
这个不起作用。我已经尝试过了。在CGImageDestinationFinalize写入磁盘之前,需要将所有图像存储在RAM中。 - klcjr89
1
我认为使用ImageIO不会有太多的好运。你试过Giraffe吗?它是一个Objective-C封装器,围绕ANGif库编写输出文件(根据我的检查)的增量方式。 - rob mayoff
显示剩余19条评论
2个回答

12
如果您将kCGImagePropertyGIFHasGlobalColorMap设置为NO,那么就不会发生内存不足的情况。

应该更加关注这个答案,因为它是创建全局颜色映射的过程,当从许多大型图像创建GIF时需要占用大量内存。禁用它将增加GIF的大小,但它将解决这里的一些或大部分内存问题。 - Scott H
@ScottH - 嗯,其实不是这样的。在多图像GIF中,kCGImagePropertyGIFHasGlobalColorMap不会起作用。'多图像GIF文件需要设置每个图像的单独属性,这意味着当源图像本身不是GIF文件时,kCGImagePropertyGIFImageColorMap将没有任何效果。' - Sebyddd
在我的测试中,使用那个设置我可以从近200帧中创建gif。 - hsafarya
我同意@hsafarya的观点。我的测试表明,这个设置对内存占用有很大的影响。我的假设是,在构建动画GIF的全局颜色映射时,算法会一次性将许多(也许全部)帧加载到内存中,以创建一个共同的(全局)颜色映射。关闭此设置后,似乎不会一次性加载太多帧,因为每个图像都用于构建自己的颜色映射。 - Scott H
我在我的一个应用程序中遇到了这个问题很长一段时间,我简直不敢相信,但这确实有效!我将 kCGImagePropertyGIFHasGlobalColorMap 添加到 GIF 属性中,它就停止崩溃了! - Tim Johnsen
显示剩余5条评论

6
您可以使用AVFoundation将图片写入视频。我已经上传了一个完整的测试项目到这个GitHub仓库。当您在模拟器中运行测试项目时,它会将文件路径打印到调试控制台。打开该路径以检查输出结果。
我将在此回答中介绍代码的重要部分。
首先创建一个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) }};
}

您可以调整比特率来控制视频文件的大小。在这里,我选择了相当保守的编解码器配置文件(它支持一些非常老的设备)。您可能希望选择更晚的配置文件。
然后,创建一个媒体类型为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决定视频何时结束(通过从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有更多的帧,我们只需返回,当准备好获取更多帧时,输入会再次调用我们:

        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;
}

核心图形将位图上下文的原点放在左下角,但我使用的是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

这可能听起来像是一个奇怪的问题,但是能否在CALayer动画正在进行时捕获它呢?这又引出了另一个问题,如果动画没有完成,如何更快地记录某些内容,以便结果与用户观看整个动画的结果相同?假设有一个动画需要花费5分钟来填充路径,我绝对不希望录制需要5分钟,但我需要在最终视频中保留动画。 - klcjr89
你从 nextFramePresentationTime 返回的时间决定了播放速度。AVFoundation 会尽可能快地对视频进行编码(这取决于 drawNextFrameInContext: 方法的速度和压缩帧的难度)。我的测试程序(在我的 Mac Pro 上)将视频编码为几分之一秒,但在 QuickTime Player 中播放时,视频会持续几秒钟。 - rob mayoff
Core Animation编程指南的“高级动画技巧”中,有两个子部分似乎与逐步执行CALayer动画相关。 - rob mayoff
AVFoundation的性质是否会逐步将数据写入磁盘?否则我可能会遇到同样的问题。 - klcjr89
3
这个被接受的答案似乎有些不公平,因为 OP 想要输出一个 GIF,而不是一个视频。 - wfbarksdale
显示剩余3条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接