用CGImageDestinationFinalize创build一个大的GIF – 内存不足

我正在尝试在创build包含大量帧的GIF时解决性能问题。 例如,一些GIF可能包含> 1200帧。 用我目前的代码,我的内存不足。 我想弄清楚如何解决这个问题。 这可以分批完成吗? 我的第一个想法是,如果有可能将图像附加在一起,但我不认为有这样的方法,或者如何由ImageIO框架创buildGIF。 这将是很好,如果有一个复数CGImageDestinationAddImages方法,但没有,所以我迷失在如何尝试解决这个问题。 我感谢提供的任何帮助。 对不起,冗长的代码,但我觉得有必要显示一步一步创build的GIF。

只要video中可能存在不同的GIF帧延迟,并且录制时间不会像每帧中所有animation的总和那样长,我就可以制作video文件而不是GIF。

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

更新1 – 6:使用GCD修复线程locking,但内存问题仍然存在。 100%的CPU利用率不在这里,因为我在工作执行时展示了一个UIActivityIndicatorView 。 使用drawViewHierarchyInRect方法可能比renderInContext方法更高效,但是我发现不能在afterScreenUpdates属性设置为YES的情况下在后台线程上使用drawViewHierarchyInRect方法; 它locking了线程。

必须有一些批量写出GIF的方法。 我相信我已经把内存问题缩小到: CGImageDestinationFinalize这个方法对于有很多帧的图像来说效率相当低,因为一切都必须在内存中写出整个图像。 我已经证实了这一点,因为我在抓取渲染的containerView图层图像和调用CGImageDestinationAddImage使用了很less的内存。 我打电话给CGImageDestinationFinalize的瞬间,记忆仪立刻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但我怀疑这是我所需要的。

看来我需要一些写入/附加GIF帧的方式增量磁盘,所以我可以清除每个新的块内存。 也许CGImageDestinationCreateWithDataConsumer用适当的callback来递增地保存数据会是理想的吗?

更新4:我开始尝试使用CGImageDestinationCreateWithDataConsumer方法来查看是否可以在使用NSFileHandlepipe理写入字节,但问题在于调用CGImageDestinationFinalizeCGImageDestinationFinalize发送的所有字节与以前相同 – 我用完了记忆。 我真的需要帮助才能解决这个问题,并会提供大量的奖励。

更新5:我已经发布了一个大赏金。 我想看看没有第三方库或框架的一些辉煌的解决scheme,将原始NSData GIF字节相互追加,并用NSFileHandle递增写入到磁盘 – 本质上是手动创buildGIF。 或者,如果你认为有一个解决scheme可以使用ImageIO就像我所尝试过的那样,那也是惊人的。 Swizzling,子类等

更新6:我一直在研究GIF是如何在最底层完成的,而且我写了一个小testing,这个testing与我要为赏金提供的帮助是一致的。 我需要抓取渲染的UIImage,从中获取字节,使用LZW进行压缩,并将字节附加到一些其他工作,如确定全局颜色表。 信息来源: http : //giflib.sourceforge.net/whatsinagif/bits_and_bytes.html 。

最新更新:

我花了整整一周的时间从各个angular度研究这个问题,看看究竟发生了什么,以便根据限制(比如最大256色)来构build质量良好的GIF。 我相信并假设ImageIO正在做的是在所有图像帧合并为一个的情况下创build一个位图上下文,并对该位图执行颜色量化以生成要在GIF中使用的单个全局颜色表。 使用一个hex编辑器从ImageIO制作的一些成功的GIF确认他们有一个全局颜色表,从来没有一个本地的,除非你自己设置每帧。 颜色量化是在这个庞大的位图上进行的,以build立一个调色板(再次假设,但强烈相信)。

我有这个怪异和疯狂的想法:来自我的应用程序的帧图像只能每帧一种颜色,甚至更好,我知道我的应用程序使用的小套颜色。 第一个/背景框架是一个框架,包含我无法控制的颜色(用户提供的内容,如照片),所以我在想的是我将快照这个视图,然后快照另一个视图与已知的颜色,我的应用程序交易并使这个单一的位图上下文,我可以传递到正常的ImaegIO GIF制作例程。 有什么优势? 那么,通过将两幅图像合并为一幅图像,就可以将其从1200帧降低到一幅。 然后, ImageIO将在小得多的位图上完成它的工作,并用一个帧写出一个GIF。

现在我能做些什么来build立实际的1200帧GIF? 我想我可以采取单帧GIF并提取颜色表字节很好,因为他们落在两个GIF协议块之间。 我仍然需要手动构buildGIF,但现在我不必计算调色板。 我会偷的调色板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与您的图像写一个video。 我已经上传了一个完整的工作testing项目到这个github仓库 。 在模拟器中运行testing项目时,它将打印到debugging控制台的文件path。 在video播放器中打开该path以检查输出。

在这个答案中,我将通过代码的重要部分。

首先创build一个AVAssetWriter 。 我会给它的AVFileTypeAppleM4V文件types,使video在iOS设备上工作。

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

用video参数设置输出设置字典:

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

您可以调整比特率来控制video文件的大小。 我在这里非常保守地select了编解码器configuration文件( 它支持一些漂亮的旧设备 )。 您可能需要select以后的configuration文件。

然后用媒体typesAVMediaTypeVideo和您的输出设置创build一个AVAssetWriterInput

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

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

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

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

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

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

将input添加到作者并告诉作者开始:

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

接下来,我们将告诉input如何获得video帧。 是的,在我们告诉作者开始写作之后,我们可以做到这一点:

  [input requestMediaDataWhenReadyOnQueue:adaptorQueue usingBlock:^{ 

这个块将做我们需要与AVFoundation做的一切。 input每次准备接受更多数据时调用它。 它可能能够在一次调用中接受多个帧,所以我们只要准备好就可以循环播放:

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

我使用self.frameGenerator实际绘制框架。 我稍后会显示该代码。 frameGenerator决定video何时结束(通过从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); 

现在我们可以将缓冲区添加到video中。 适配器做到这一点:

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

上面的循环通过适配器推送帧,直到input表明它已经足够了,或者直到frameGenerator表示它没有帧。 如果frameGenerator有更多的框架,我们只是返回,当它准备好更多的帧时,input会再次调用我们:

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

如果frameGenerator超出帧数,我们closuresinput:

  [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 

对于我的testing,我绘制一个背景图像,随着video的进展,慢慢用红色覆盖它。

 @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将原点放置在位图上下文的左下angular,但是我使用的是UIImage ,UIKit喜欢在左上angular有原点。

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

我打电话给我的testing程序用来更新进度指示器的callback:

  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则不会发生内存不足。