本文主要是讲如何将一张照片和一部视频合并为动态照片,然后存入照片库。
动态照片(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
}
其中,图片和视频的
必须一致。
使用代码添加元数据
知道了动态照片的秘密,那么事情就好办了,我们只需要添加相应的元数据到图片和视频中就可以了。
虽然不需要对视频重新编码,但是也没法简单地使用一两行代码就可以完成元数据的插入。只能使用AVAssetReader
和AVAssetWriter
边读边写。
前提
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
生成。
视频添加元数据
给视频添加元数据非常麻烦,需要使用AVAssetReader
和AVAssetWriter
,前者读的同时,后者同时写。
在给出详细代码前,先对AVAssetReader
和AVAssetWriter
有个大概的认识。
AVAssetReader
AVAsset
可以看成视频对象。 AVAssetReader
可以看成是AVAsset
的数据读取管理器,它不负责读数据,只负责读取状态变更。 AVAssetReaderOutput
可以看成数据读取器,负责数据的读取工作,它需要加入到AVAssetReader
中才能工作。可以创建多个AVAssetReaderOutput
加入到AVAssetReader
中。 AVAssetReaderTrackOutput
是AVAssetReaderOutput
的子类,传入[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、 初始化AVAssetReader
和AVAssetWriter
。
2、 通过AVAsset
获取轨道,用于创建AVAssetReaderTrackOutput
,以及对应的AVAssetWriterInput
。
3、 AVAssetReader
读取顶层元数据,修改后让AVAssetWriter
写入顶层元数据。
4、 让AVAssetReader
和AVAssetWriter
进入读写状态。
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
必须赋值,否则在插入到元数据轨道时会出错。
创建AVAssetReader
和AVAssetWriter
首先,定义一个添加元数据到视频中的入口方法。
- (void)addMetadataToVideo:(NSURL *)videoURL outputFile:(NSString *)outputFile identifier:(NSString *)identifier;
然后,创建AVAssetReader
和AVAssetWriter
,同时添加顶层元数据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];
创建AVAssetReaderTrackOutput
和AVAssetWriterInput
// 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];
}
此处,保存AVAssetReader
和AVAssetWriter
对象以及dispatch_queue_t
和dispatch_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); }
}];
针对照片,PHAssetResourceType
为Photo
。
针对视频,PHAssetResourceType
为PairedVideo
。
完整代码
完整代码已上传至GitHub: DeviLeo/LivePhotoConverter。