如何制作Live Photo并存入照片库

本文主要是讲如何将一张照片和一部视频合并为动态照片,然后存入照片库。

动态照片(Live Photo)的秘密

动态照片表面上看就是一张图片加一部短视频,它们在手机中的文件名是相同的,只是扩展名不同,一个是JPG,一个是MOV,都是大写的。

注:iOS是区分大小写的,而macOS是不区分大小写的,但是在显示的时候是区分大小写的。

我曾经试图将电脑中的短视频,外加一张截图,把它们名字改成一样的,然后放入我的手机中(/User/Media/DCIM/100APPLE/),接着删除照片库的数据库(/User/Media/PhotoData/Photos.sqlite),重启手机再进入照片库,发现系统只能认出截图,并没有将它识别为动态照片。

上StackOverflow搜索发现,图片和视频中都有额外的元数据用于识别动态照片。
1、 图片(JPEG)

  • 元数据(Metadata)
{
  "{MakerApple}" : {
    "17" : ""
  }
}

2、 视频(MOV)

  • H.264编码
  • YUV420P颜色编码
  • 顶层元数据(Metadata)
{
  "com.apple.quicktime.content.identifier" : ""
}
  • 元数据轨道(Metadata Track)
{
  "MetadataIdentifier" : "mdta/com.apple.quicktime.still-image-time",
  "MetadataDataType" : "com.apple.metadata.datatype.int8"
}
  • 元数据轨道中的元数据
{
  "com.apple.quicktime.still-image-time" : 0
}

其中,图片和视频的必须一致。

使用代码添加元数据

知道了动态照片的秘密,那么事情就好办了,我们只需要添加相应的元数据到图片和视频中就可以了。
虽然不需要对视频重新编码,但是也没法简单地使用一两行代码就可以完成元数据的插入。只能使用AVAssetReaderAVAssetWriter边读边写。

前提

1、iOS版本需要9.1以上,否则不支持将动态照片存入照片库。
2、导入以下头文件。

#import 
#import 
#import 

图片添加元数据

给图片添加元数据非常简单,代码如下。

- (void)addMetadataToPhoto:(NSURL *)photoURL outputPhotoFile:(NSString *)outputFile identifier:(NSString *)identifier {
    NSMutableData *data = [NSData dataWithContentsOfURL:photoURL].mutableCopy;
    UIImage *image = [UIImage imageWithData:data];
    CGImageRef imageRef = image.CGImage;
    NSDictionary *imageMetadata = @{(NSString *)kCGImagePropertyMakerAppleDictionary : @{@"17" : identifier}};
    CGImageDestinationRef dest = CGImageDestinationCreateWithData((CFMutableDataRef)data, kUTTypeJPEG, 1, nil);
    CGImageDestinationAddImage(dest, imageRef, (CFDictionaryRef)imageMetadata);
    CGImageDestinationFinalize(dest);
    [data writeToFile:outputFile atomically:YES];
}

其中,kCGImagePropertyMakerAppleDictionary的值是{MakerApple}identifier的值由[NSUUID UUID].UUIDString生成。

视频添加元数据

给视频添加元数据非常麻烦,需要使用AVAssetReaderAVAssetWriter,前者读的同时,后者同时写。
在给出详细代码前,先对AVAssetReaderAVAssetWriter有个大概的认识。

AVAssetReader

AVAsset可以看成视频对象。
AVAssetReader可以看成是AVAsset的数据读取管理器,它不负责读数据,只负责读取状态变更。
AVAssetReaderOutput可以看成数据读取器,负责数据的读取工作,它需要加入到AVAssetReader中才能工作。可以创建多个AVAssetReaderOutput加入到AVAssetReader中。
AVAssetReaderTrackOutputAVAssetReaderOutput的子类,传入[AVAsset tracks]中的轨道来创建轨道数据读取器。
[AVAssetReader startReading]表示AVAssetReaderTrackOutput可以开始读取数据。
[AVAssetReaderOutput copyNextSampleBuffer]表示读取下一段数据。可以是音频数据,也可以是视频数据,也可以是其它数据。
[AVAssetReader cancelReading]表示停止读取数据。停止后任何AVAssetReaderOutput都无法读取数据。

AVAssetWriter

AVAssetWriter可以看成是视频数据写入管理器,它不负责写数据,只负责写入状态的变更。
AVAssetWriterInput可以看成轨道数据写入器,负责数据的写入工作,它需要加入到AVAssetWriter才能工作。可以创建多个AVAssetWriterInput加入到AVAssetWriter中。
[AVAssetWriter startWriting]表示AVAssetWriterInput可以开始写入数据。
[AVAssetWriter startSessionAtSourceTime:kCMTimeZero]表示从音频或视频时间第0秒开始写入数据。
AVAssetWriterInput.readyForMoreMediaData表示有足够的缓冲区可供写入数据。
AVAssetWriterInput appendSampleBuffer:buffer]表示将数据buffer写入缓冲区。当AVAssetWriterInput的缓冲区写满时,会对数据进行处理并清空缓冲区。
注意:如果有多个AVAssetWriterInput,当其中一个AVAssetWriterInput写满缓冲区时,并不会对数据进行处理,而是等待其它AVAssetWriterInput写入相应时长的数据后,才会对数据进行处理。
[AVAssetWriterInput markAsFinished]表示已经没有数据可以写入,并且不再接收任何数据。
[AVAssetWriter finishWritingWithCompletionHandler表示所有写入已经完成,处理所有数据,生成一个完整的视频。

读写流程

1、 初始化AVAssetReaderAVAssetWriter
2、 通过AVAsset获取轨道,用于创建AVAssetReaderTrackOutput,以及对应的AVAssetWriterInput
3、 AVAssetReader读取顶层元数据,修改后让AVAssetWriter写入顶层元数据。
4、 让AVAssetReaderAVAssetWriter进入读写状态。
5、 AVAssetReaderOuput读取轨道数据,AVAssetWriterInput写入轨道数据。
6、 数据全部读完后,让AVAssetReader变为停止读取状态。让所有AVAssetWriterInput标记为写完状态。
7、 让AVAssetWriter变为完成状态,至此视频创建完成。

代码详解

创建顶层元数据
- (AVMetadataItem *)createContentIdentifierMetadataItem:(NSString *)identifier {
    AVMutableMetadataItem *item = [AVMutableMetadataItem metadataItem];
    item.keySpace = AVMetadataKeySpaceQuickTimeMetadata;
    item.key = AVMetadataQuickTimeMetadataKeyContentIdentifier;
    item.value = identifier;
    return item;
}

此处视频的identifier必须和图片的identifier的值一样。

创建元数据轨道
- (AVAssetWriterInput *)createStillImageTimeAssetWriterInput {
    NSArray *spec = @[@{(NSString *)kCMMetadataFormatDescriptionMetadataSpecificationKey_Identifier : @"mdta/com.apple.quicktime.still-image-time",
                        (NSString *)kCMMetadataFormatDescriptionMetadataSpecificationKey_DataType : (NSString *)kCMMetadataBaseDataType_SInt8 }];
    CMFormatDescriptionRef desc = NULL;
    CMMetadataFormatDescriptionCreateWithMetadataSpecifications(kCFAllocatorDefault, kCMMetadataFormatType_Boxed, (__bridge CFArrayRef)spec, &desc);
    AVAssetWriterInput *input = [AVAssetWriterInput assetWriterInputWithMediaType:AVMediaTypeMetadata outputSettings:nil sourceFormatHint:desc];
    return input;
}
创建元数据轨道中的元数据
- (AVMetadataItem *)createStillImageTimeMetadataItem {
    AVMutableMetadataItem *item = [AVMutableMetadataItem metadataItem];
    item.keySpace = AVMetadataKeySpaceQuickTimeMetadata;
    item.key = @"com.apple.quicktime.still-image-time";
    item.value = @(-1);
    item.dataType = (NSString *)kCMMetadataBaseDataType_SInt8;
    return item;
}

注意:这里dataType必须赋值,否则在插入到元数据轨道时会出错。

创建AVAssetReaderAVAssetWriter

首先,定义一个添加元数据到视频中的入口方法。

- (void)addMetadataToVideo:(NSURL *)videoURL outputFile:(NSString *)outputFile identifier:(NSString *)identifier;

然后,创建AVAssetReaderAVAssetWriter,同时添加顶层元数据com.apple.quicktime.content.identifier

NSError *error = nil;
  
// Reader
AVAsset *asset = [AVAsset assetWithURL:videoURL];
AVAssetReader *reader = [AVAssetReader assetReaderWithAsset:asset error:&error];
if (error) {
    NSLog(@"Init reader error: %@", error);
    return;
}
  
// Add content identifier metadata item
NSMutableArray *metadata = asset.metadata.mutableCopy;
AVMetadataItem *item = [self createContentIdentifierMetadataItem:identifier];
[metadata addObject:item];
  
// Writer
NSURL *videoFileURL = [NSURL fileURLWithPath:outputFile];
[self deleteFile:outputFile];
AVAssetWriter *writer = [AVAssetWriter assetWriterWithURL:videoFileURL fileType:AVFileTypeQuickTimeMovie error:&error];
if (error) {
    NSLog(@"Init writer error: %@", error);
    return;
}
[writer setMetadata:metadata];
创建AVAssetReaderTrackOutputAVAssetWriterInput
// Tracks
NSArray *tracks = [asset tracks];
for (AVAssetTrack *track in tracks) {
    NSDictionary *readerOutputSettings = nil;
    NSDictionary *writerOuputSettings = nil;
    if ([track.mediaType isEqualToString:AVMediaTypeAudio]) {
        readerOutputSettings = @{AVFormatIDKey : @(kAudioFormatLinearPCM)};
        writerOuputSettings = @{AVFormatIDKey : @(kAudioFormatMPEG4AAC),
                                AVSampleRateKey : @(44100),
                                AVNumberOfChannelsKey : @(2),
                                AVEncoderBitRateKey : @(128000)};
    }
    AVAssetReaderTrackOutput *output = [AVAssetReaderTrackOutput assetReaderTrackOutputWithTrack:track outputSettings:readerOutputSettings];
    AVAssetWriterInput *input = [AVAssetWriterInput assetWriterInputWithMediaType:track.mediaType outputSettings:writerOuputSettings];
    if ([reader canAddOutput:output] && [writer canAddInput:input]) {
        [reader addOutput:output];
        [writer addInput:input];
    }
}

针对音频轨道,在AVAssetReaderTrackOutput读取数据时,解码生成kAudioFormatLinearPCM编码格式的数据。在AVAssetWriterInput写入数据时,以kAudioFormatMPEG4AAC编码格式进行编码写入,如果不想重新编码,可以将nil传入outputSettings参数,这样最后生成的音频轨道是kAudioFormatLinearPCM编码格式。
针对视频轨道outputSettings参数传入nil表示不重新编码。

**注意:
根据官方文档,AVAssetReaderTrackOutput只能生成无压缩的编码格式。
针对音频轨道,只能是kAudioFormatLinearPCM
针对视频轨道,无压缩编码格式需要遵循AVVideoSettings.h文件中指定的规则。当然,出于性能考虑,对于设备支持的原生解码器,可以不用转换。例如,使用YUV420P颜色编码的H.264编码的视频就不需要转换。
完整的文档内容如下。**

The track must be one of the tracks contained by the target AVAssetReader's asset.  
  
A value of nil for outputSettings configures the output to vend samples in their original format as stored by the specified track.  
Initialization will fail if the output settings cannot be used with the specified track.  
  
AVAssetReaderTrackOutput can only produce uncompressed output.  
For audio output settings, this means that AVFormatIDKey must be kAudioFormatLinearPCM.  
For video output settings, this means that the dictionary must follow the rules for uncompressed video output, as laid out in AVVideoSettings.h.  
AVAssetReaderTrackOutput does not support the AVAudioSettings.h key AVSampleRateConverterAudioQualityKey or the following AVVideoSettings.h keys:  
  
  AVVideoCleanApertureKey  
  AVVideoPixelAspectRatioKey  
  AVVideoScalingModeKey  
  
When constructing video output settings the choice of pixel format will affect the performance and quality of the decompression.  
For optimal performance when decompressing video the requested pixel format should be one that the decoder supports natively to avoid unnecessary conversions.  
Below are some recommendations:  
  
For H.264 use kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, or kCVPixelFormatType_420YpCbCr8BiPlanarFullRange if the video is known to be full range.  
For JPEG on iOS, use kCVPixelFormatType_420YpCbCr8BiPlanarFullRange.  
  
For other codecs on OSX, kCVPixelFormatType_422YpCbCr8 is the preferred pixel format for video and is generally the most performant when decoding.  
If you need to work in the RGB domain then kCVPixelFormatType_32BGRA is recommended on iOS and kCVPixelFormatType_32ARGB is recommended on OSX.  
  
ProRes encoded media can contain up to 12bits/ch.  
If your source is ProRes encoded and you wish to preserve more than 8bits/ch during decompression then use one of the following pixel formats:  
kCVPixelFormatType_4444AYpCbCr16, kCVPixelFormatType_422YpCbCr16, kCVPixelFormatType_422YpCbCr10, or kCVPixelFormatType_64ARGB.  
AVAssetReader does not support scaling with any of these high bit depth pixel formats.  
If you use them then do not specify kCVPixelBufferWidthKey or kCVPixelBufferHeightKey in your outputSettings dictionary.  
If you plan to append these sample buffers to an AVAssetWriterInput then note that only the ProRes encoders support these pixel formats.  
  
ProRes 4444 encoded media can contain a mathematically lossless alpha channel.  
To preserve the alpha channel during decompression use a pixel format with an alpha component such as kCVPixelFormatType_4444AYpCbCr16 or kCVPixelFormatType_64ARGB.  
To test whether your source contains an alpha channel check that the track's format description has kCMFormatDescriptionExtension_Depth and that its value is 32.
创建元数据轨道
// Metadata track
AVAssetWriterInput *input = [self createStillImageTimeAssetWriterInput];
AVAssetWriterInputMetadataAdaptor *adaptor = [AVAssetWriterInputMetadataAdaptor assetWriterInputMetadataAdaptorWithAssetWriterInput:input];
if ([writer canAddInput:input]) {
    [writer addInput:input];
}

其中,AVAssetWriterInputMetadataAdaptor的作用是将 元数据(Metadata) 作为 校准元数据组(Timed Metadata Groups) 写入单个AVAssetWriterInput

开始读写
// Start reading and writing
[writer startWriting];
[writer startSessionAtSourceTime:kCMTimeZero];
[reader startReading];
将元数据写入元数据轨道
// Write metadata track's metadata
AVMetadataItem *timedItem = [self createStillImageTimeMetadataItem];
CMTimeRange timedRange = CMTimeRangeMake(kCMTimeZero, CMTimeMake(1, 100));
AVTimedMetadataGroup *timedMetadataGroup = [[AVTimedMetadataGroup alloc] initWithItems:@[timedItem] timeRange:timedRange];
[adaptor appendTimedMetadataGroup:timedMetadataGroup];

**注意:
timedRange必须是个有范围的值,如果为0,则会写入失败。
[AVTimedMetadataGroup appendTimedMetadataGroup:]必须在[AVAssetWriter startWriting]之后才能写入。**

异步读写轨道数据
// Write other tracks
self.reader = reader;
self.writer = writer;
self.queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
self.group = dispatch_group_create();
for (NSInteger i = 0; i < reader.outputs.count; ++i) {
    dispatch_group_enter(self.group);
    [self writeTrack:i];
}

此处,保存AVAssetReaderAVAssetWriter对象以及dispatch_queue_tdispatch_group_t,会在主要的读写操作方法- (void)writeTrack:(NSInteger)trackIndex;中使用。
使用dispatch_group是为了在异步读写各个轨道数据全部完成后,进行最后的收尾工作。
至此,- (void)addMetadataToVideo:(NSURL *)videoURL outputFile:(NSString *)outputFile identifier:(NSString *)identifier;方法的代码已经全部完成。
下面是异步读写轨道数据的代码。

- (void)writeTrack:(NSInteger)trackIndex {
    AVAssetReaderOutput *output = self.reader.outputs[trackIndex];
    AVAssetWriterInput *input = self.writer.inputs[trackIndex];
    
    [input requestMediaDataWhenReadyOnQueue:self.queue usingBlock:^{
        while (input.readyForMoreMediaData) {
            AVAssetReaderStatus status = self.reader.status;
            CMSampleBufferRef buffer = NULL;
            if ((status == AVAssetReaderStatusReading) &&
                (buffer = [output copyNextSampleBuffer])) {
                BOOL success = [input appendSampleBuffer:buffer];
                CFRelease(buffer);
                if (!success) {
                    NSLog(@"Track %d. Failed to append buffer.", (int)trackIndex);
                    [input markAsFinished];
                    dispatch_group_leave(self.group);
                    return;
                }
            } else {
                if (status == AVAssetReaderStatusReading) {
                    NSLog(@"Track %d complete.", (int)trackIndex);
                } else if (status == AVAssetReaderStatusCompleted) {
                    NSLog(@"Reader completed.");
                } else if (status == AVAssetReaderStatusCancelled) {
                    NSLog(@"Reader cancelled.");
                } else if (status == AVAssetReaderStatusFailed) {
                    NSLog(@"Reader failed.");
                }
                [input markAsFinished];
                dispatch_group_leave(self.group);
                return;
            }
        }
    }];
}

[AVAssetWriterInput requestMediaDataWhenReadyOnQueue:usingBlock:]的block中应该不停地将数据添加到AVAssetWriterInput,直到AVAssetWriterInput.readyForMoreMediaData属性值变为NO,或者没有数据可供添加(通常会调用[AVAssetWriterInput markAsFinished]方法)。然后退出block。
退出block后,且[AVAssetWriterInput markAsFinished]还没有被调用,一旦AVAssetWriterInput处理完数据,AVAssetWriterInput.readyForMoreMediaData属性值就会变为YES,block将会再次被调用,以获取更多的数据。

收尾工作

当数据全部读完写完之后,进行收尾工作。

- (void)finishWritingTracksWithPhoto:(NSString *)photoFile video:(NSString *)videoFile complete:(void (^)(BOOL success, NSString *photoFile, NSString *videoFile, NSError *error))complete {
    [self.reader cancelReading];
    [self.writer finishWritingWithCompletionHandler:^{
        if (complete) complete(YES, photoFile, videoFile, nil);
    }];
}

简单地停止读取和完成写入即可,在视频文件完全生成之后会有回调,在回调中将视频存入照片库即可。

封装

图片和视频的元数据的添加代码都已经完成,将其封装一下,代码如下。

- (void)useAssetWriter:(NSURL *)photoURL video:(NSURL *)videoURL identifier:(NSString *)identifier complete:(void (^)(BOOL success, NSString *photoFile, NSString *videoFile, NSError *error))complete {
    // Photo
    NSString *photoName = [photoURL lastPathComponent];
    NSString *photoFile = [self filePathFromDoc:photoName];
    [self addMetadataToPhoto:photoURL outputFile:photoFile identifier:identifier];
    
    // Video
    NSString *videoName = [videoURL lastPathComponent];
    NSString *videoFile = [self filePathFromDoc:videoName];
    [self addMetadataToVideo:videoURL outputFile:videoFile identifier:identifier];
    
    if (!self.group) return;
    dispatch_group_notify(self.group, dispatch_get_main_queue(), ^{
        [self finishWritingTracksWithPhoto:photoFile video:videoFile complete:complete];
    });
}

存入照片库

检查设备是否支持动态照片

BOOL available = [PHAssetCreationRequest supportsAssetResourceTypes:@[@(PHAssetResourceTypePhoto), @(PHAssetResourceTypePairedVideo)]];
if (!available) {
    NSLog(@"Device does NOT support LivePhoto.");
    return;
}

授权

访问照片库之前,需要进行授权。
首先在Info.plst文件中添加NSPhotoLibraryUsageDescription键值对。
程序运行时,主动请求授权。

[PHPhotoLibrary requestAuthorization:^(PHAuthorizationStatus status) {
    if (status != PHAuthorizationStatusAuthorized) {
        NSLog(@"Photo Library access denied.");
        return;
    }
}];

存入照片库

NSURL *photo = [NSURL fileURLWithPath:photoFile];
NSURL *video = [NSURL fileURLWithPath:videoFile];
  
[[PHPhotoLibrary sharedPhotoLibrary] performChanges:^{
    PHAssetCreationRequest *request = [PHAssetCreationRequest creationRequestForAsset];
    [request addResourceWithType:PHAssetResourceTypePhoto fileURL:photo options:nil];
    [request addResourceWithType:PHAssetResourceTypePairedVideo fileURL:video options:nil];
} completionHandler:^(BOOL success, NSError * _Nullable error) {
    if (success) { NSLog(@"Saved."); }
    else { NSLog(@"Save error: %@", error); }
}];

针对照片,PHAssetResourceTypePhoto
针对视频,PHAssetResourceTypePairedVideo

完整代码

完整代码已上传至GitHub: DeviLeo/LivePhotoConverter

参考

你可能感兴趣的:(ios,objective-c)