Подтвердить что ты не робот

Создание большого GIF с CGImageDestinationFinalize - нехватка памяти

Я пытаюсь исправить проблему с производительностью при создании GIF с большим количеством кадров. Например, некоторые GIF могут содержать > 1200 кадров. С моим текущим кодом у меня заканчивается память. Я пытаюсь выяснить, как это решить; это можно сделать партиями? Моя первая идея заключалась в том, чтобы было возможно объединять изображения вместе, но я не думаю, что существует способ для этого или как GIF создаются инфраструктурой ImageIO. Было бы неплохо, если бы существовал множественный метод CGImageDestinationAddImages, но его нет, поэтому я теряюсь на том, как его решить. Я ценю любую помощь. Извините заранее за длинный код, но я чувствовал, что необходимо показать пошаговое создание GIF.

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

Примечание: переход к последнему заголовку обновления ниже, чтобы пропустить предысторию.

Обновления 1 - 6: Фиксация потока зафиксирована с помощью GCD, но проблема с памятью все еще остается. 100% загрузка процессора здесь не является проблемой, так как я показываю UIActivityIndicatorView, пока выполняется работа. Использование метода drawViewHierarchyInRect может быть более эффективным/быстрым, чем метод renderInContext, однако я обнаружил, что вы не можете использовать метод drawViewHierarchyInRect в фоновом потоке с свойством afterScreenUpdates, установленным в YES; он блокирует поток.

Должен быть какой-то способ записи GIF в партиях. Я считаю, что я сузил проблему с памятью до: CGImageDestinationFinalize Этот метод кажется довольно неэффективным для создания изображений с большим количеством кадров, поскольку все должно быть в памяти, чтобы записать весь образ. Я подтвердил это, потому что я использую небольшую память, захватывая отображаемые изображения слоя containerView и вызываю CGImageDestinationAddImage. В тот момент, когда я звоню CGImageDestinationFinalize, счетчик памяти мгновенно всплывает; иногда до 2 ГБ на основе количества кадров. Объем требуемой памяти просто кажется сумасшедшим, чтобы создать GIF ~ 20-1000 КБ.

Обновление 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 с соответствующими обратными вызовами для сохранения данных постепенно?

Обновление 4: Я начал пробовать метод CGImageDestinationCreateWithDataConsumer, чтобы узнать, могу ли я управлять записью байтов при использовании NSFileHandle, но опять же проблема заключается в том, что вызов CGImageDestinationFinalize отправляет все байты в один снимок, который является Как и раньше, я исчерпал память. Мне действительно нужна помощь, чтобы решить эту проблему и предложите большую щедрость.

Обновление 5: Я опубликовал большую щедрость. Я хотел бы увидеть некоторые блестящие решения без сторонней библиотеки или фреймворка для добавления необработанных байтов формата NSData GIF друг к другу и поэтапно записать его на диск с помощью NSFileHandle - по существу создавая GIF вручную. Или, если вы считаете, что есть решение, которое можно найти с помощью ImageIO, как то, что я пробовал, это тоже удивительно. Swizzling, подклассы и т.д.

Обновление 6: Я изучал, как GIF создаются на самом низком уровне, и я написал небольшой тест, который соответствует тому, что я собираюсь с помощью помощи наград. Мне нужно захватить обработанный UIImage, получить из него байты, сжать его с помощью LZW и добавить байты вместе с некоторыми другими работами, такими как определение глобальной таблицы цветов. Источник информации: http://giflib.sourceforge.net/whatsinagif/bits_and_bytes.html.

Последнее обновление:

Я проводил всю неделю, исследуя это со всех сторон, чтобы понять, что именно происходит, чтобы создать достойные качества GIF на основе ограничений (например, 256 цветов). Я полагаю и предполагаю, что делает ImageIO, создавая единый контекст растрового изображения под капотом со всеми кадрами изображений, объединенными как один, и выполняет квантование цвета на этом растровом изображении, чтобы создать единую глобальную таблицу цветов, которая будет использоваться в GIF. Использование шестнадцатеричного редактора на некоторых успешных GIF файлах, сделанных из ImageIO, подтверждает, что у них есть глобальная таблица цветов и никогда не имеет локальной, если вы не установите ее для каждого фрейма самостоятельно. Цветовое квантование выполняется на этом огромном растровом изображении для создания цветовой палитры (опять же предполагая, но твердо веря).

У меня есть эта странная и сумасшедшая идея: образы рамок из моего приложения могут отличаться только на один цвет за кадр и еще лучше, я знаю, какие небольшие наборы цветов используют мое приложение. Первый/фоновый фрейм - это кадр, который содержит цвета, которые я не могу контролировать (пользовательский контент, такой как фотографии), поэтому я думаю, что я сниму это представление, а затем сниму другое представление с тем, что имеет известные цвета, которыми занимается мое приложение с и сделать это одним растровым контекстом, который я могу передать в обычные программы ImaegIO GIF. Какое преимущество? Наилучшим образом, это получает его от ~ 1200 кадров до одного путем слияния двух изображений в одно изображение. ImageIO выполнит свою работу на гораздо меньшем растровом изображении и выпишет один GIF с одним фреймом.

Теперь, что я могу сделать, чтобы создать реальный GIF файл в формате 1200? Я думаю, что я могу взять 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 '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];
}
4b9b3361

Ответ 1

Вы можете использовать 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) }};
}

Вы можете настроить битрейт для управления размером вашего видеофайла. Здесь я довольно осторожно выбрал профиль кодека (он поддерживает некоторые довольно старые устройства). Возможно, вы захотите выбрать более поздний профиль.

Затем создайте AVAssetWriterInput с типом носителя AVMediaTypeVideo и вашими настройками вывода.

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 решает, когда видео закончилось (возвращая NO из hasNextFrame). Он также знает, когда каждый кадр должен появиться на экране:

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

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

Ответ 2

Если вы установили kCGImagePropertyGIFHasGlobalColorMap в NO, то нехватки памяти не будет.