TwitterImagePipeline/TIPImageContainer.m (371 lines of code) (raw):
//
// TIPImageContainer.m
// TwitterImagePipeline
//
// Created on 10/8/15.
// Copyright © 2020 Twitter. All rights reserved.
//
#import "TIP_Project.h"
#import "TIPError.h"
#import "TIPGlobalConfiguration+Project.h"
#import "TIPImageCodecCatalogue.h"
#import "TIPImageContainer.h"
#import "TIPImageUtils.h"
#import "UIImage+TIPAdditions.h"
NS_ASSUME_NONNULL_BEGIN
@interface TIPImageContainer ()
@property (nonatomic, readonly) NSUInteger loopCount;
@end
@implementation TIPImageContainer
{
NSArray<NSNumber *> *_frameDurations;
UIImage *_image;
}
- (instancetype)initWithImage:(UIImage *)image
animated:(BOOL)animated
loopCount:(NSUInteger)loopCount
frameDurations:(nullable NSArray<NSNumber *> *)durations
{
if (!image) {
@throw [NSException exceptionWithName:NSInvalidArgumentException
reason:@"MUST provide non-nil image argument!"
userInfo:nil];
}
if (durations != nil && durations.count != image.images.count) {
TIPLogWarning(@"Provided animation frame durations count doesn't equal number of animation frames! Reverting to UIImage.duration for calculating the animation frame durations");
durations = nil;
}
if (self = [super init]) {
_image = image;
_animated = animated;
_loopCount = loopCount;
_frameDurations = [durations copy];
}
return self;
}
- (instancetype)initWithImage:(UIImage *)image
{
return [self initWithImage:image
animated:(image.images.count > 1)
loopCount:0
frameDurations:nil];
}
- (instancetype)initWithAnimatedImage:(UIImage *)image
loopCount:(NSUInteger)loopCount
frameDurations:(nullable NSArray<NSNumber *> *)durations
{
return [self initWithImage:image
animated:(image.images.count > 1)
loopCount:loopCount
frameDurations:durations];
}
- (UIImage *)image
{
if (!_image) {
[[TIPGlobalConfiguration sharedInstance] postProblem:TIPProblemImageContainerHasNilImage userInfo:@{}];
}
return (UIImage * __nonnull)_image;
}
- (NSUInteger)frameCount
{
return (_animated) ? _image.images.count : 1;
}
- (nullable NSArray<UIImage *> *)frames
{
return _image.images;
}
- (nullable NSArray<NSNumber *> *)frameDurations
{
if (!_frameDurations && _animated) {
const NSUInteger count = _image.images.count;
NSNumber *duration = @(_image.duration / count);
NSMutableArray *durations = [[NSMutableArray alloc] initWithCapacity:count];
for (NSUInteger i = 0; i < count; i++) {
[durations addObject:duration];
}
_frameDurations = [durations copy];
}
return _frameDurations;
}
- (nullable UIImage *)frameAtIndex:(NSUInteger)index
{
if (_animated) {
if (index < _image.images.count) {
return _image.images[index];
}
} else {
if (index == 0) {
return _image;
}
}
return nil;
}
- (NSTimeInterval)frameDurationAtIndex:(NSUInteger)index
{
if (!_animated) {
return 0.0;
}
NSArray *durations = self.frameDurations;
return durations.count > index ? [durations[index] doubleValue] : 0.0;
}
- (NSString *)description
{
NSMutableString *description = [NSMutableString stringWithFormat:@"<%@ : %p", NSStringFromClass([self class]), self];
[description appendFormat:@", size=%@, scale=%f", NSStringFromCGSize(_image.size), _image.scale];
if (self.isAnimated) {
[description appendFormat:@", frames=%tu, loopCount=%tu, durations=[%@]", self.frameCount, self.loopCount, [self.frameDurations componentsJoinedByString:@" "]];
}
[description appendFormat:@">"];
return description;
}
- (id)descriptor
{
NSValue *dimensions = [NSValue valueWithCGSize:[self dimensions]];
if (self.isAnimated) {
return @{
@"dimensions" : dimensions,
@"frameDurations" : self.frameDurations ?: @[],
@"loopCount" : @(self.loopCount)
};
}
return @{ @"dimensions" : dimensions };
}
@end
@implementation TIPImageContainer (Convenience)
+ (nullable instancetype)imageContainerWithImage:(UIImage *)image descriptor:(id)descriptor
{
TIPAssert(image != nil);
TIPAssert(descriptor != nil);
if (!image || !descriptor) {
return nil;
}
NSDictionary *d = descriptor;
if (![d isKindOfClass:[NSDictionary class]]) {
return nil;
}
CGSize dims = [(NSValue*)d[@"dimensions"] CGSizeValue];
if (!CGSizeEqualToSize(image.tip_dimensions, dims)) {
return nil;
}
NSArray<NSNumber *> *frameDurations = d[@"frameDurations"];
if (frameDurations && ![frameDurations isKindOfClass:[NSArray class]]) {
return nil;
}
if (frameDurations && image.images.count <= 1) {
return nil;
}
if (frameDurations && frameDurations.count != image.images.count) {
return nil;
}
const NSUInteger loopCount = (frameDurations != nil) ? [d[@"loopCount"] unsignedIntegerValue] : 0;
return [[self alloc] initWithImage:image
animated:frameDurations != nil
loopCount:loopCount
frameDurations:frameDurations];
}
//! returns negative value if all the images are the same size (aka is an animation)
static CFIndex _DetectLargestNonAnimatedImageIndex(CGImageSourceRef imageSource)
{
const size_t count = CGImageSourceGetCount(imageSource);
TIPAssert(count > 0);
if (count == 1) {
// definitely not animated
return 0;
}
/**
We have multiple frames...
If all the frames are the same size, we have an animation.
If any of the frames differ in size, we have a set of images in a single container, use the largest one.
Look at the first 5 frames (or count of frames, whichever is less).
If first frames are all the same size, treat image as an animation (return -1)
Else if the size in first frames differs, continue through all frames to find largest (return that index)
*/
BOOL allEqual = YES;
CFIndex largestIndex = -1;
CGSize largestSize = CGSizeZero;
static const size_t kMaxFramesAllEqualCountLimit = 5;
const size_t maxFramesAllEqualCount = MIN(count, kMaxFramesAllEqualCountLimit);
for (size_t i = 0; (allEqual ? (i < maxFramesAllEqualCount) : (i < count)); i++) {
const CGSize size = TIPDetectImageSourceDimensionsAtIndex(imageSource, i);
if (CGSizeEqualToSize(size, CGSizeZero)) {
// no dimensions, skip
continue;
}
if (size.width > largestSize.width && size.height > largestSize.height) {
largestSize = size;
if (largestIndex < 0) {
// never had a largest size yet
largestIndex = (CFIndex)i;
} else {
// already had a largest size
allEqual = NO;
largestIndex = (CFIndex)i;
}
} else if (size.width < largestSize.width && size.height < largestSize.height) {
TIPAssert(largestIndex >= 0);
allEqual = NO;
}
}
return (allEqual) ? -1 : largestIndex;
}
+ (nullable instancetype)imageContainerWithImageSource:(CGImageSourceRef)imageSource
{
return [self imageContainerWithImageSource:imageSource
targetDimensions:CGSizeZero
targetContentMode:UIViewContentModeCenter];
}
+ (nullable instancetype)imageContainerWithImageSource:(CGImageSourceRef)imageSource
targetDimensions:(CGSize)targetDimensions
targetContentMode:(UIViewContentMode)targetContentMode
{
if (!imageSource) {
return nil;
}
const size_t count = CGImageSourceGetCount(imageSource);
if (!count) {
return nil;
}
const CFIndex index = _DetectLargestNonAnimatedImageIndex(imageSource);
if (index >= 0) {
// not animated
UIImage *image = [UIImage tip_imageWithImageSource:imageSource
atIndex:(NSUInteger)index
targetDimensions:targetDimensions
targetContentMode:targetContentMode];
return (image) ? [(TIPImageContainer *)[[self class] alloc] initWithImage:image] : nil;
}
// made it here, means we are animated
NSArray *durations;
NSUInteger loopCount;
UIImage *image = [UIImage tip_imageWithAnimatedImageSource:imageSource
targetDimensions:targetDimensions
targetContentMode:targetContentMode
durations:&durations
loopCount:&loopCount];
return [(TIPImageContainer *)[[self class] alloc] initWithAnimatedImage:image
loopCount:loopCount
frameDurations:durations];
}
+ (nullable instancetype)imageContainerWithData:(NSData *)data
decoderConfigMap:(nullable NSDictionary<NSString *,id> *)decoderConfigMap
codecCatalogue:(nullable TIPImageCodecCatalogue *)catalogue
{
return [self imageContainerWithData:data
targetDimensions:CGSizeZero
targetContentMode:UIViewContentModeCenter
decoderConfigMap:decoderConfigMap
codecCatalogue:catalogue];
}
+ (nullable instancetype)imageContainerWithData:(NSData *)data
targetDimensions:(CGSize)targetDimensions
targetContentMode:(UIViewContentMode)targetContentMode
decoderConfigMap:(nullable NSDictionary<NSString *, id> *)decoderConfigMap
codecCatalogue:(nullable TIPImageCodecCatalogue *)catalogue
{
if (!catalogue) {
catalogue = [TIPImageCodecCatalogue sharedInstance];
}
return [catalogue decodeImageWithData:data
targetDimensions:targetDimensions
targetContentMode:targetContentMode
decoderConfigMap:decoderConfigMap
imageType:NULL];
}
+ (nullable instancetype)imageContainerWithFilePath:(NSString *)filePath
decoderConfigMap:(nullable NSDictionary<NSString *,id> *)decoderConfigMap
codecCatalogue:(nullable TIPImageCodecCatalogue *)catalogue
memoryMap:(BOOL)map
{
return [self imageContainerWithFilePath:filePath
targetDimensions:CGSizeZero
targetContentMode:UIViewContentModeCenter
decoderConfigMap:decoderConfigMap
codecCatalogue:catalogue
memoryMap:map];
}
+ (nullable instancetype)imageContainerWithFilePath:(NSString *)filePath
targetDimensions:(CGSize)targetDimensions
targetContentMode:(UIViewContentMode)targetContentMode
decoderConfigMap:(nullable NSDictionary<NSString *, id> *)decoderConfigMap
codecCatalogue:(nullable TIPImageCodecCatalogue *)catalogue
memoryMap:(BOOL)map
{
return [self imageContainerWithFileURL:[NSURL fileURLWithPath:filePath isDirectory:NO]
targetDimensions:targetDimensions
targetContentMode:targetContentMode
decoderConfigMap:decoderConfigMap
codecCatalogue:catalogue
memoryMap:map];
}
+ (nullable instancetype)imageContainerWithFileURL:(NSURL *)fileURL
decoderConfigMap:(nullable NSDictionary<NSString *,id> *)decoderConfigMap
codecCatalogue:(nullable TIPImageCodecCatalogue *)catalogue
memoryMap:(BOOL)map
{
return [self imageContainerWithFileURL:fileURL
targetDimensions:CGSizeZero
targetContentMode:UIViewContentModeCenter
decoderConfigMap:decoderConfigMap
codecCatalogue:catalogue
memoryMap:map];
}
+ (nullable instancetype)imageContainerWithFileURL:(NSURL *)fileURL
targetDimensions:(CGSize)targetDimensions
targetContentMode:(UIViewContentMode)targetContentMode
decoderConfigMap:(nullable NSDictionary<NSString *, id> *)decoderConfigMap
codecCatalogue:(nullable TIPImageCodecCatalogue *)catalogue
memoryMap:(BOOL)map
{
if (!fileURL.isFileURL) {
return nil;
}
NSData *data = nil;
if (map) {
data = [NSData dataWithContentsOfURL:fileURL
options:NSDataReadingMappedIfSafe
error:NULL];
} else {
data = [NSData dataWithContentsOfURL:fileURL];
}
if (!data) {
return nil;
}
return [self imageContainerWithData:data
targetDimensions:targetDimensions
targetContentMode:targetContentMode
decoderConfigMap:decoderConfigMap
codecCatalogue:catalogue];
}
- (NSUInteger)sizeInMemory
{
return [self.image tip_estimatedSizeInBytes];
}
- (CGSize)dimensions
{
return [self.image tip_dimensions];
}
- (CGSize)pointSize
{
return [self.image tip_pointSize];
}
- (BOOL)saveToFilePath:(NSString *)path
type:(nullable NSString *)type
codecCatalogue:(nullable TIPImageCodecCatalogue *)catalogue
options:(TIPImageEncodingOptions)options
quality:(float)quality
atomic:(BOOL)atomic
error:(out NSError * __autoreleasing __nullable * __nullable)error
{
if (!catalogue) {
catalogue = [TIPImageCodecCatalogue sharedInstance];
}
if (!type) {
const TIPRecommendedImageTypeOptions recoOptions = TIPRecommendedImageTypeOptionsFromEncodingOptions(options, quality);
type = [self.image tip_recommendedImageType:recoOptions];
}
return [catalogue encodeImage:self
toFilePath:path
withImageType:type
quality:quality
options:options
atomic:atomic
error:error];
}
- (nullable TIPImageContainer *)scaleToTargetDimensions:(CGSize)dimensions
contentMode:(UIViewContentMode)contentMode
{
TIPAssert(self.image != nil);
UIImage *image = [self.image tip_scaledImageWithTargetDimensions:dimensions
contentMode:contentMode];
if (!image) {
return nil;
}
return [[TIPImageContainer alloc] initWithImage:image
animated:self.isAnimated
loopCount:self.loopCount
frameDurations:self.frameDurations];
}
- (void)decode
{
[self.image tip_decode];
}
@end
NS_ASSUME_NONNULL_END