AVFoundation框架(三) - 媒体资源和元数据

通过使用资源对象,并通过构建它的元数据编辑来了解AVFoundation的特性.

1. 媒体资源AVAsset

AVFoundation 中最重要的就是AVAsset这个抽象类,它定义了媒体资源混合呈现的方式,将媒体资源的静态属性模块化一个整体.比如标题,时长,元数据等.
我们使用AVAssetTrack可以从AVAsset资源容器中拿到轨道信息和上面的内容。

AVAsset主要是抽象化了基本媒体资源的格式 , 跟资源文件一对一映射, 因此我们就不需考虑不种格式,而使用单一统一的方式处理.

  • 创建资源
例: 从照片库中视频文件创建一个AVAsset资源
  PHPhotoLibrary *libary = [PHPhotoLibrary sharedPhotoLibrary];
  [libary performChanges:^{
    // 获取视频类资源.可以用类似 NSArray 的接口来访问结果内的集合。它会按需动态加载内容并且缓存最近请求的内容
    PHFetchResult *result = [PHAsset fetchAssetsWithMediaType:PHAssetMediaTypeVideo options:nil];

    // 获取第一个视频.
    [result enumerateObjectsAtIndexes:[NSIndexSet indexSetWithIndex:0] options:0 usingBlock:^(PHAsset   * _Nonnull  phAsset, NSUInteger idx, BOOL * _Nonnull stop) {
      
      if (phAsset) {
        __weak __typeof(self) weakSelf = self;
        // PHAsset转换 AVAsset 
        [[PHImageManager defaultManager] requestAVAssetForVideo:phAsset options:nil resultHandler:^(AVAsset * avAsset, AVAudioMix * audioMix, NSDictionary * info) {
         // dispatch_async(dispatch_get_main_queue(), ^{ // 按需要添加
            [weakSelf doSomethingWithAVAsset:avAsset];
          //});
        }];
      }
    }];
  } completionHandler:^(BOOL success, NSError * _Nullable error) {
    if (!success) {
      NSLog(@"PHPhotoLibrary error:%@",error);
    }
  }];

AVAsset资源通常使用assetWithURL:方法来获取,但是iOS使用新的了Photos照片库,没有了url的定义,所以这里用PHImageManager来进行转换.

  • 访问资源属性:AVAsset使用一种高效的设计模式,即延迟加载资源的属性,只有当使用时候才加载.这样可以快速创建资源 . 但有时资源属性的访问是同步发生的,而正在请求的属性没有预先载入时,就会造成程序阻塞. 所以**要使用异步方式查询资源的属性. **
    AVAsset和AVAssetTrack通过AVAsynchronousKeyValueLoading协议实现异步查询属性功能.
- (void)doSomethingWithAVAsset:(AVAsset*) asset {
  NSArray *keys = @[@"tracks"];
  // 异步加载资源的tracks属性
  [asset loadValuesAsynchronouslyForKeys:keys completionHandler:^{
    // 先捕获 tracks 属性状态,再据此做处理
    NSError *error = nil;
    AVKeyValueStatus status = [asset statusOfValueForKey:@"tracks" error:&error];
    switch (status) {
      case AVKeyValueStatusLoaded:
        break;
      case AVKeyValueStatusFailed:
        break;
      default:
        ;
    }
  }];
}

如果想访问资源的多个属性时, 虽然loadValuesAsynchronouslyForKeys:只会调用一次,但是每个属性的状态不一定一致,所以要分开判断.

1.1 元数据

每个媒体资源类型都具有唯一的格式,因此开发者通常需要对相应格式的底层读写操作有所了解. 而AVFoundation 框架中的媒体格式都嵌入到了描述其内容的元数据中. 所以我们可以使用一套统一的方法来直接处理元数据 .

**元数据格式: ** Apple环境下媒体类型主要是: QuickTime(mov)、MPEG-4 video(mp4,mpv) 和MPEG-4 audio(m4a)、MPEG-Layer III audio (mp3);

  1. QuickTime
    QuickTime定义了.mov文件的内部结构. 通常一部QuickTime电影会包含两种类型元数据:标准元数据(/moov/meta/ilst/中)和用户元数据(/moov/udta/中).顾名思义: 用户元数据就是包括演唱者,版权信息以及任何对应用程序有帮助的额外信息.
  2. MPEG-4 音频和视频
    MP4文件直接派生于QuickTime文件格式,所以很多解析QuickTime文件的工具也能解析MPEG-4.它的元数据保存在/moov/udta/meta/ilst中

该类型文件还有一些变化的扩展名,如.m4v,.m4a,.m4p和.m4b. 它们都使用MPEG-4容器格式,但包含了一些附加的扩展功能;m4v是带有苹果公司针对FairPlay加密和AC3-audio扩展的格式.m4a专门针对音频,告诉使用者此文件只含音频资源.m4b用于有声读物,通常包含章节标签、书签等功能.

  1. MP3
    MP3文件与上面两种有显著区别,MP3不适用容器格式,而使用编码音频数据,使用ID3v2格式来保存音频的描述信息,包含演唱者,唱片公司等信息.AVFoundation只支持MP3的解码读取.
1.2 获取元数据

AVAsset和AVAssetTrack都可以实现查询相关元数据的功能,通过AVMetadataItem类的接口来访问元数据.大部分情况我们使用AVAsset,除非你要获取低层级元数据的信息. 另外AVFoundation使用键空间(keys space)将相关键组合在一起.以方便实现对AVMetadataItem实例集合分类筛选.每个资源至少包含两个键空间: commonMetadataavailableMetadataFormats. 前者用来定义所有支持的媒体类型的键,包括:曲名,歌手,插图等常见元素. 后者用来包含用来定义元数据格式的NSString对象和相关元数据信息的NSArray.

#define COMMON_META_KEY     @"commonMetadata"
#define AVAILABLE_META_KEY  @"availableMetadataFormats"
// 两种键空间
    NSArray *keys = @[COMMON_META_KEY, AVAILABLE_META_KEY];
// ios支持的元数据格式
    NSArray *acceptedFormats = @[                                                
            AVMetadataFormatQuickTimeMetadata,
            AVMetadataFormatiTunesMetadata,
            AVMetadataFormatID3Metadata
        ];

    [asset loadValuesAsynchronouslyForKeys:keys completionHandler:^{ 
  // 1. 判断资源属性加载状态.
        AVKeyValueStatus commonStatus =
            [self.asset statusOfValueForKey:COMMON_META_KEY error:nil];
        AVKeyValueStatus formatsStatus =
            [self.asset statusOfValueForKey:AVAILABLE_META_KEY error:nil];
        self.prepared = (commonStatus == AVKeyValueStatusLoaded) &&         
                        (formatsStatus == AVKeyValueStatusLoaded);
  // 2. 获取各键空间的元数据
        if (self.prepared) {
            for (AVMetadataItem *item in self.asset.commonMetadata) {       
                //NSLog(@"%@: %@", item.keyString, item.value);

            }

            for (NSString *format in self.asset.availableMetadataFormats) { // 查询此资源中所包含的所有元数据格式
                if ([acceptedFormats containsObject:format]) {  // 是否支持此格式.
                    NSArray *items = [self.asset metadataForFormat:format]; // 访问对应格式元数据.
                    for (AVMetadataItem *item in items) {
                        //NSLog(@"%@: %@", item.keyString, item.value);
            
                    }
                }
            }
        }
    }];
}

AVMetadataItem 最基本形式是一个键值对的容器.可以通过它查询key或commonKey来访问元数据. 但是它的key属性是以id<NSObject,NSCopying>值的形式定义的,虽然可以保存NSString,但通过上面的打印可以知道Key只是无意义的整数.所以我们最好添加一个AVMetadataItem分类方法,用来转换获取key的内容.代码如下:

@interface AVMetadataItem (THAdditions)
@property (readonly) NSString *keyString;

// .m
- (NSString *)keyString {
    if ([self.key isKindOfClass:[NSString class]]) {                        
        return (NSString *)self.key;
    } else if ([self.key isKindOfClass:[NSNumber class]]) {
        UInt32 keyValue = [(NSNumber *) self.key unsignedIntValue];         

         //大部分keys 有 4 字符长度,而 ID3v2.2 格式的keys 只有3个字符,下面代码表示移动每个字节来确定length长度是要截短.
        size_t length = sizeof(UInt32);                                     
        if ((keyValue >> 24) == 0) --length;
        if ((keyValue >> 16) == 0) --length;
        if ((keyValue >> 8) == 0) --length;
        if ((keyValue >> 0) == 0) --length;
        
        long address = (unsigned long)&keyValue;
        address += (sizeof(UInt32) - length);

        // keys 是以big-endian(高位优先)格式存储的.需要转换成符合主流CPU顺序的little-endian格式. 
        keyValue = CFSwapInt32BigToHost(keyValue);                         

        // 创建一个字符数组,以keys字符字节填充
        char cstring[length];                                               
        strncpy(cstring, (char *) address, length);
        cstring[length] = '\0';

        // 大部分QuickTime和iTunes keys前缀都有一个 '©', 而AVMetadataFormat.h 用'@' 表示,所以转换一下.
        if (cstring[0] == '\xA9') {                                         
            cstring[0] = '@';
        }

        return [NSString stringWithCString:(char *) cstring                 
                                  encoding:NSUTF8StringEncoding];
    }
    else {
        return @"<<unknown>>";
    }
}

除了通过键和键空间获取资源的元数据之外,iOS 8之后添加了用identifier获取元数据的方法. 标识符将键和键空间统一成单独的字符串,以一个更简单的模型来获取资源的元数据,具体参考AVMetadataItem.h, 这里使用键和键空间是为了方便兼容以前的系统.

1.3元数据的解析

通过上面的方法获得元数据以及keys属性的内容转换之后,接下来到了最难的部分,就是理解key对应value中的数据.当value是一个简单字符串时,比如歌手或唱片名称或年份,这样容易理解的是不需要转换的.但是有很多复杂key的value需要转换解析:

  • Artwork:
    元数据Artwork对应的value会以多种不同的格式返回,比如封面和海报等,它保存在一个NSData中,我们要先定位
// 解析
- (id)displayValueFromMetadataItem:(AVMetadataItem *)item {
    NSImage *image = nil;
    if ([item.value isKindOfClass:[NSData class]]) {       
        image = [[NSImage alloc] initWithData:item.dataValue];
    }
    else if ([item.value isKindOfClass:[NSDictionary class]]) {   // 如果对象是MP3格式.value就可能是个字典.
        NSDictionary *dict = (NSDictionary *)item.value;
        image = [[NSImage alloc] initWithData:dict[@"data"]];
    }
    return image;
}
// 恢复原格式
- (AVMetadataItem *)metadataItemFromDisplayValue:(id)value
                                withMetadataItem:(AVMetadataItem *)item {

    AVMutableMetadataItem *metadataItem = [item mutableCopy];

    NSImage *image = (NSImage *)value;
    metadataItem.value = image.TIFFRepresentation;                          
    return metadataItem;
}
  • 注释:
    当处理对象是MPEG-4或QuickTime时,可以直接获取对应字符串,如果是mp3格式:
// 解析
- (id)displayValueFromMetadataItem:(AVMetadataItem *)item {

    NSString *value = nil;
    if ([item.value isKindOfClass:[NSString class]]) {                      // 1
        value = item.stringValue;
    }
    else if ([item.value isKindOfClass:[NSDictionary class]]) {             // 2
        NSDictionary *dict = (NSDictionary *) item.value;
        if ([dict[@"identifier"] isEqualToString:@""]) {
            value = dict[@"text"];
        }
    }
    return value;
}
// 恢复
- (AVMetadataItem *)metadataItemFromDisplayValue:(id)value
                                withMetadataItem:(AVMetadataItem *)item {

    AVMutableMetadataItem *metadataItem = [item mutableCopy];               // 3
    metadataItem.value = value;
    return metadataItem;
}
  • 音轨数据信息:
    音轨通常包含一首歌在整个唱片中的编号位置信息.
// 解析
- (id)displayValueFromMetadataItem:(AVMetadataItem *)item {
    
    NSNumber *number = nil;
    NSNumber *count = nil;
    
    if ([item.value isKindOfClass:[NSString class]]) {  // MP3音频信息以"xx/xx"字符串格式返回.例如一个包含10首歌曲的唱片中第8首就是8/10;
        NSArray *components =
            [item.stringValue componentsSeparatedByString:@"/"];
        number = @([components[0] integerValue]);
        count = @([components[1] integerValue]);
    }
    else if ([item.value isKindOfClass:[NSData class]]) {  // 对于MPEG-4格式,则复杂点.
        NSData *data = item.dataValue;
        if (data.length == 8) {
            uint16_t *values = (uint16_t *) [data bytes];
            if (values[1] > 0) {
                number = @(CFSwapInt16BigToHost(values[1]));               
            }
            if (values[2] > 0) {
                count = @(CFSwapInt16BigToHost(values[2]));                 
            }
        }
    }
    
    NSMutableDictionary *dict = [NSMutableDictionary dictionary];           
    [dict setObject:number ?: [NSNull null] forKey:THMetadataKeyTrackNumber]; // 得到的音轨编号
    [dict setObject:count ?: [NSNull null] forKey:THMetadataKeyTrackCount];// 得到的音轨计数

    return dict;
}

// 恢复
- (AVMetadataItem *)metadataItemFromDisplayValue:(id)value
                                withMetadataItem:(AVMetadataItem *)item {

    AVMutableMetadataItem *metadataItem = [item mutableCopy];

    NSDictionary *trackData = (NSDictionary *)value;
    NSNumber *trackNumber = trackData[THMetadataKeyTrackNumber];
    NSNumber *trackCount = trackData[THMetadataKeyTrackCount];

    uint16_t values[4] = {0};                                                // 6
    
    if (trackNumber && ![trackNumber isKindOfClass:[NSNull class]]) {
        values[1] = CFSwapInt16HostToBig([trackNumber unsignedIntValue]);   // 7
    }
    
    if (trackCount && ![trackCount isKindOfClass:[NSNull class]]) {
        values[2] = CFSwapInt16HostToBig([trackCount unsignedIntValue]);    // 8
    }
    
    size_t length = sizeof(values);
    metadataItem.value = [NSData dataWithBytes:values length:length];       // 9

    return metadataItem;
}
  • 风格信息:
    乡村,爵士,蓝调等等.
// 转换
- (id)displayValueFromMetadataItem:(AVMetadataItem *)item {

    THGenre *genre = nil;

    if ([item.value isKindOfClass:[NSString class]]) {                      // 1
        if ([item.keySpace isEqualToString:AVMetadataKeySpaceID3]) {
            // ID3v2.4 stores the genre as an index value
            if (item.numberValue) {                                         // 2
                NSUInteger genreIndex = [item.numberValue unsignedIntValue];
                genre = [THGenre id3GenreWithIndex:genreIndex];
            } else {
                genre = [THGenre id3GenreWithName:item.stringValue];        // 3
            }
        } else {
            genre = [THGenre videoGenreWithName:item.stringValue];          // 4
        }
    }
    else if ([item.value isKindOfClass:[NSData class]]) {                   // 5
        NSData *data = item.dataValue;
        if (data.length == 2) {
            uint16_t *values = (uint16_t *)[data bytes];
            uint16_t genreIndex = CFSwapInt16BigToHost(values[0]);
            genre = [THGenre iTunesGenreWithIndex:genreIndex];
        }
    }
    return genre;
}
//恢复
- (AVMetadataItem *)metadataItemFromDisplayValue:(id)value
                                withMetadataItem:(AVMetadataItem *)item {

    AVMutableMetadataItem *metadataItem = [item mutableCopy];

    THGenre *genre = (THGenre *)value;

    if ([item.value isKindOfClass:[NSString class]]) {                      // 6
        metadataItem.value = genre.name;
    }
    else if ([item.value isKindOfClass:[NSData class]]) {                   // 7
        NSData *data = item.dataValue;
        if (data.length == 2) {
            uint16_t value = CFSwapInt16HostToBig(genre.index + 1);         // 8
            size_t length = sizeof(value);
            metadataItem.value = [NSData dataWithBytes:&value length:length];
        }
    }

    return metadataItem;
}
1.4 导出修改后的元数据

通过上面的解析转换方法,我们就可以进行元数据信息的读取与修改,修改完当然需要保存了.不过中间还有一个问题: 由于AVAsset是一个不可变类,所以我们不能直接修改AVAsset,而是使用AVAssetExportSession类导出一个新的资源副本.

  • AVAssetExportSession配置
    AVAssetExportSession是用于将AVAsset内容根据预设的导出条件进行转码,并写入磁盘中,用它可以实现将一种格式转换成另一种格式,修订资源内容,修改资源的音视频行为.也包含了写入新的元数据.
    所以创建AVAssetExportSession实例要先提供资源和预设条件; 导出预设用于确定导出内容的质量,大小等属性. 创建完成后还需要指定一个outputURL写入地址,并且给outputFileType一个格式.代码如下:
- (void)saveWithCompletionHandler:(THCompletionHandler)handler {
  // 先用AVAssetExportPresetPassthrough预设值创建一个AVAssetExportSession
    NSString *presetName = AVAssetExportPresetPassthrough;                  
    AVAssetExportSession *session =
        [[AVAssetExportSession alloc] initWithAsset:self.asset
                                         presetName:presetName];
  // 配置导出预设
    NSURL *outputURL = [self tempURL];                                      
    session.outputURL = outputURL;
    session.outputFileType = self.filetype;
  // 用上面提到的解析与恢复方法修改元数据并返回
    session.metadata = [self.metadata metadataItems];                       

  // 最后异步导出修改后的元数据
    [session exportAsynchronouslyWithCompletionHandler:^{
        AVAssetExportSessionStatus status = session.status;
        BOOL success = (status == AVAssetExportSessionStatusCompleted);
        if (success) {                                                      // 4
            NSURL *sourceURL = self.url;
            NSFileManager *manager = [NSFileManager defaultManager];
            [manager removeItemAtURL:sourceURL error:nil];
            [manager moveItemAtURL:outputURL toURL:sourceURL error:nil];
        }
        
        if (handler) {
            dispatch_async(dispatch_get_main_queue(), ^{
                handler(success);
            });
        }
    }];
}

AVAssetExportPresetPassthrough预设值允许修改预设值,但是不能用于添加元数据,如果要添加元数据,需要使用转码预设值.

推荐阅读更多精彩内容