iOS视频硬编码技术

iOS视频硬编码技术

一.iOS视频采集硬编码

基本原理

硬编码 & 软编码

硬编码:通过系统自带的Camera录制视频,实际上调用的是底层的高清编码硬件模块,即显卡,不使用CPU,速度快

软编码:使用CPU进行编码,如常见C/C++代码,编译生成二进制文件,速度相对较慢。例如使用Android NDK编译H264生成so库,编写jni接口,再使用java调用so库。

硬编码过程和原理

过程:通过MediaRecoder采集视频,再将视频流映射到LocalSocket实现收发。

视频采集步骤

主要是通过< AVFoundation >框架进行采集

  • 1.创建会话
  • 2.设置视频的输入
  • 3.设置视频的输出
  • 获取摄像头方向
  • 切换摄像头
  • 实现代理 -- AVCaptureVideoDataOutputSampleBufferDelegat
  • 停止采集
·          AVCaptureSession *captureSession = [[AVCaptureSession alloc] init];
·          captureSession.sessionPreset = AVCaptureSessionPreset1280x720;
·          self.captureSession = captureSession;
·          // 2.1获取输入设备,摄像头,默认为后置
·          AVCaptureDevice *videoDevice = [self getVideoDevice:AVCaptureDevicePositionBack];
·         
·          //2.2创建对应视频输入对象
·          AVCaptureDeviceInput *videoDeviceInput = [AVCaptureDeviceInput deviceInputWithDevice:videoDevice error:nil];
·         
·          //2.3添加到会话中
·          // 注意:最好要判断是否能添加输入,会话不能添加空的
·          if ([captureSession canAddInput:videoDeviceInput])   {
·           [captureSession addInput:videoDeviceInput];
·          }
    // 3.1获取视频输出设备
    AVCaptureVideoDataOutput *videoOutput = [[AVCaptureVideoDataOutput alloc] init];
    //默认为YES,接收器会立即丢弃接收到的帧,在代理里面,NO的时候,将允许在丢弃之前有更多时间处理旧帧
   // [videoOutput setAlwaysDiscardsLateVideoFrames:NO];
    // 3.2 设置输出代理,捕获视频样品数据
    dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
    [videoOutput setSampleBufferDelegate:self queue:queue];
    if ([captureSession canAddOutput:videoOutput]) {
        [captureSession addOutput:videoOutput];
    }
    
    //3.3 设置视频输出方向
    // 注意:设置方向,必须在videoOutput添加到captureSession之后,否则出错
    AVCaptureConnection *connection = [videoOutput connectionWithMediaType:AVMediaTypeVideo];
    if (connection.isVideoOrientationSupported) {
        [connection setVideoOrientation:AVCaptureVideoOrientationPortrait];
    }
 

4. 添加视频预览层

AVCaptureVideoPreviewLayer *layer = [AVCaptureVideoPreviewLayer layerWithSession:captureSession];
self.preViewlayer = layer;
[layer setVideoGravity:AVLayerVideoGravityResizeAspect];
layer.frame = preview.bounds;
[preview.layer insertSublayer:layer atIndex:0];

5.开始采集

[captureSession startRunning];
//指定摄像头方向,获取摄像头
- (AVCaptureDevice *)getVideoDevice:(AVCaptureDevicePosition)position{
    NSArray *devices = [AVCaptureDevice devicesWithMediaType:AVMediaTypeVideo];
    for (AVCaptureDevice *device in devices) {
        if (device.position == position) {
            return device;
        }
    }
    return nil;
}
// 切换采集摄像头
- (void)switchScene:(UIView *)preview{
    // 1.添加动画
    CATransition *rotaionAnim = [[CATransition alloc] init];
    rotaionAnim.type = @"oglFlip";
    rotaionAnim.subtype = @"fromLeft";
    rotaionAnim.duration = 0.5;
    [preview.layer addAnimation:rotaionAnim forKey:nil];
    
    // 2.获取当前镜头
    AVCaptureDevicePosition position = self.videoDeviceInput.device.position == AVCaptureDevicePositionBack ? AVCaptureDevicePositionFront : AVCaptureDevicePositionBack;
    
    // 3.创建新的input对象
    AVCaptureDevice *newDevice = [self getVideoDevice:position];
    AVCaptureDeviceInput *newDeviceInput = [AVCaptureDeviceInput deviceInputWithDevice:newDevice error:nil];
    
    // 4.移除旧输入,添加新输入
    [self.captureSession beginConfiguration];
    [self.captureSession removeInput:self.videoDeviceInput];
    [self.captureSession addInput:newDeviceInput];
    // 此处要重新设置视频输出方向,默认会旋转90度
    self.connection = [_videoOutput connectionWithMediaType:AVMediaTypeVideo];
    if (_connection.isVideoOrientationSupported) {
        [_connection setVideoOrientation:AVCaptureVideoOrientationPortrait];
    }
 
    [self.captureSession commitConfiguration];
    // 5.保存新输入
    self.videoDeviceInput = newDeviceInput;
}
 
- (void)captureOutput:(AVCaptureOutput *)captureOutput didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer fromConnection:(AVCaptureConnection *)connection{
    NSLog(@"获取到一帧数据");
    // 对获取到的数据进行编码,编码部分在下一篇继续讲
    dispatch_sync(mEncodeQueue, ^{
        [self.encoder encodeFrame:sampleBuffer];
    });
}
- (void)stopCapturing{
    // 停止扫描
    [self.captureSession stopRunning];
    // 移除预览图层
    [self.preViewlayer removeFromSuperlayer];
    // 将对象置为nil
    self.captureSession = nil;
}

 

6. iOS音视频采集硬编码

关于音视频采集硬编码,为方便项目的参考。用到AVCaptureSession来进行音视频数据采集,AVCaptureSession是用来管理视频与数据的捕获,采集到音频原始数据pcm(pcm是指未经过压缩处理的)压缩为aac格式,采集到yuv420格式的视频帧压缩成h.264格式。

demo:https://github.com/oopsr/AVDecode

二.iOS视频开发:视频H264硬编码

已经介绍了如何采集iOS摄像头的视频数据,采集到的原始视频数据量是比较大的,这么大的数据量不利于进行储存或网络传输。需要对视频数据进行压缩,就像你要向别人传文件时觉得文件太大了,打个rar压缩包再发给对方的道理一样。视频数据的压缩也叫做编码,H264是一种视频编码格式,iOS 8.0及以上苹果开放了VideoToolbox框架来实现H264硬编码,开发者可以利用VideoToolbox框架很方便地实现视频的硬编码。下面将分以下几部分内容来讲解H264硬编码在iOS中的实现:
1、介绍视频编码的基本概念
2、VideoToolbox实现硬编码原理及流程
3、代码实现硬编码
4、总结及Demo


基本概念

视频数据为什么可以压缩呢,因为视频数据存在冗余。通俗地理解,例如一个视频中,前一秒画面跟当前的画面内容相似度很高,那么这两秒的数据是不是可以不用全部保存,只保留一个完整的画面,下一个画面看有哪些地方有变化了记录下来,拿视频去播放的时候就按这个完整的画面和其他有变化的地方把其他画面也恢复出来。记录画面不同然后保存下来这个过程就是数据编码,根据不同的地方恢复画面的过程就是数据解码。

H264是一种视频编码标准,在H264协议里定义了三种帧:

  • I帧:完整编码的帧,也叫关键帧
  • P帧:参考之前的I帧生成的只包含差异部分编码的帧
  • B帧:参考前后的帧编码的帧叫B帧

H264采用的核心算法是帧内压缩和帧间压缩,帧内压缩是生成I帧的算法,帧间压缩是生成B帧和P帧的算法。
H264原始码流是由一个接一个的NALU(Nal Unit)组成的,NALU = 开始码 + NAL类型 + 视频数据
开始码用于标示这是一个NALU 单元的开始,必须是"00 00 00 01" 或"00 00 01"
NALU类型如下:

类型

说明

0

未规定

1

非IDR图像中不采用数据划分的片段

2

非IDR图像中A类数据划分片段

3

非IDR图像中B类数据划分片段

4

非IDR图像中C类数据划分片段

5

IDR图像的片段

6

补充增强信息(SEI)

7

序列参数集(SPS)

8

图像参数集(PPS)

9

分割符

10

序列结束符

11

流结束符

12

填充数据

13

序列参数集扩展

14

带前缀的NAL单元

15

子序列参数集

16 – 18

保留

19

不采用数据划分的辅助编码图像片段

20

编码片段扩展

21 – 23

保留

24 – 31

未规定

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

一般只用到了1、5、7、8这4个类型就够了。类型为5表示这是一个I帧,I帧前面必须有SPS和PPS数据,也就是类型为7和8,类型为1表示这是一个P帧或B帧。

帧率:单位为fps(frame pre second),视频画面每秒有多少帧画面,数值越大画面越流畅
码率:单位为bps(bit pre second),视频每秒输出的数据量,数值越大画面越清晰
分辨率:视频画面像素密度,例如常见的720P、1080P等
关键帧间隔:每隔多久编码一个关键帧
软编码:使用CPU进行编码。性能较差
硬编码:不使用CPU进行编码,使用显卡GPU,专用的DSP、FPGA、ASIC芯片等硬件进行编码。性能较好

VideoToolbox实现H264硬编码

iOS8.0及以上可以通过VideoToolbox实现视频数据的硬编解码。VideoToolbox基本数据结构:

  • CVPixelBufferRef/CVImageBufferRef:存放编码前和解码后的图像数据,这俩货其实是同一个东西
  • CMTime:时间戳相关,时间以64-bit/32-bit的形式出现
  • CMBlockBufferRef:编码后输出的数据
  • CMFormatDescriptionRef/CMVideoFormatDescriptionRef:图像存储方式,编解码器等格式描述。这俩货也是同一个东西
  • CMSampleBufferRef:存放编解码前后的视频图像的容器数据
  • CMSampleBuffer编解码前后数据结构                                                                                                                                                                                                                                                                        
基本步骤

1、通过VTCompressionSessionCreate创建编码器
2、通过VTSessionSetProperty设置编码器属性
3、设置完属性调用VTCompressionSessionPrepareToEncodeFrames准备编码
4、输入采集到的视频数据,调用VTCompressionSessionEncodeFrame进行编码
5、获取到编码后的数据并进行处理
6、调用VTCompressionSessionCompleteFrames停止编码器
7、调用VTCompressionSessionInvalidate销毁编码器

1、创建编码器

VTCompressionSessionCreate用来创建视频编码会话,这个方法有10个参数,可以看一下苹果对这个API的注释

VTCompressionSessionCreate(
    CM_NULLABLE CFAllocatorRef                          allocator,
    int32_t                                             width,
    int32_t                                             height,
    CMVideoCodecType                                    codecType,
    CM_NULLABLE CFDictionaryRef                         encoderSpecification,
    CM_NULLABLE CFDictionaryRef                         sourceImageBufferAttributes,
    CM_NULLABLE CFAllocatorRef                          compressedDataAllocator,
    CM_NULLABLE VTCompressionOutputCallback             outputCallback,
    void * CM_NULLABLE                                  outputCallbackRefCon,
    CM_RETURNS_RETAINED_PARAMETER CM_NULLABLE VTCompressionSessionRef * CM_NONNULL compressionSessionOut)

allocator:内存分配器,填NULL为默认分配器
widthheight:视频帧像素的宽高,如果编码器不支持这个宽高的话可能会改变
codecType:编码类型,枚举
encoderSpecification:指定特定的编码器,填NULL的话由VideoToolBox自动选择
sourceImageBufferAttributes:源像素缓冲区的属性,如果这个参数有值的话,VideoToolBox会创建一个缓冲池,不需要缓冲池可以设置为NULL
compressedDataAllocator:压缩后数据的内存分配器,填NULL使用默认分配器
outputCallback:视频编码后输出数据回调函数
outputCallbackRefCon:回调函数中的自定义指针,通常传self,在回调函数中就可以拿到当前类的方法和属性了
compressionSessionOut:编码器句柄,传入编码器的指针

OSStatus status = VTCompressionSessionCreate(NULL, 180, 320, kCMVideoCodecType_H264, NULL, NULL, NULL, encodeOutputDataCallback, (__bridge void *)(self), &_compressionSessionRef);
2、设置编码器属性 & 准备编码

编码器创建完了,所有给编码器设置属性都是调用VTSessionSetProperty方法来实现。

kVTCompressionPropertyKey_AverageBitRate:设置编码的平均码率,单位是bps,这不是一个硬性指标,设置的码率会上下浮动。VideoToolBox框架只支持ABR模式。H264有4种码率控制方法:

  • CBR(Constant Bit Rate)是以恒定比特率方式进行编码,有Motion发生时,由于码率恒定,只能通过增大QP来减少码字大小,图像质量变差,当场景静止时,图像质量又变好,因此图像质量不稳定。这种算法优先考虑码率(带宽)。
  • VBR(Variable Bit Rate)动态比特率,其码率可以随着图像的复杂程度的不同而变化,因此其编码效率比较高,Motion发生时,马赛克很少。码率控制算法根据图像内容确定使用的比特率,图像内容比较简单则分配较少的码率(似乎码字更合适),图像内容复杂则分配较多的码字,这样既保证了质量,又兼顾带宽限制。这种算法优先考虑图像质量。
    *CVBR(Constrained VariableBit Rate),这样翻译成中文就比较难听了,它是VBR的一种改进方法。但是Constrained又体现在什么地方呢?这种算法对应的Maximum bitRate恒定或者Average BitRate恒定。这种方法的兼顾了以上两种方法的优点:在图像内容静止时,节省带宽,有Motion发生时,利用前期节省的带宽来尽可能的提高图像质量,达到同时兼顾带宽和图像质量的目的。
  • ABR (Average Bit Rate) 在一定的时间范围内达到设定的码率,但是局部码率峰值可以超过设定的码率,平均码率恒定。可以作为VBR和CBR的一种折中选择。

kVTCompressionPropertyKey_ProfileLevel:设置H264编码的画质,H264有4种Profile:BP、EP、MP、HP

BP(Baseline Profile):基本画质。支持I/P 帧,只支持无交错(Progressive)和CAVLC;主要应用:可视电话,会议电视,和无线通讯等实时视频通讯领域
EP(Extended profile):进阶画质。支持I/P/B/SP/SI 帧,只支持无交错(Progressive)和CAVLC;
MP(Main profile):主流画质。提供I/P/B 帧,支持无交错(Progressive)和交错(Interlaced),也支持CAVLC 和CABAC 的支持;主要应用:数字广播电视和数字视频存储
HP(High profile):高级画质。在main Profile 的基础上增加了8×8内部预测、自定义量化、 无损视频编码和更多的YUV 格式;应用于广电和存储领域

Level就多了,这里不一一列举,可参考h264 profile & level,iPhone上常用的方案如下:

  • 实时直播:
    低清Baseline Level 1.3
    标清Baseline Level 3
    半高清Baseline Level 3.1
    全高清Baseline Level 4.1
  • 存储媒体:
    低清 Main Level 1.3
    标清 Main Level 3
    半高清 Main Level 3.1
    全高清 Main Level 4.1
  • 高清存储:
    半高清 High Level 3.1
    全高清 High Level 4.1

kVTCompressionPropertyKey_RealTime:设置是否实时编码输出
kVTCompressionPropertyKey_AllowFrameReordering:配置是否产生B帧,High profile 支持 B 帧
kVTCompressionPropertyKey_MaxKeyFrameIntervalkVTCompressionPropertyKey_MaxKeyFrameIntervalDuration:配置I帧间隔

设置完编码器的属性后,调用VTCompressionSessionPrepareToEncodeFrames准备编码

// 设置码率 512kbps
OSStatus status = VTSessionSetProperty(_compressionSessionRef, kVTCompressionPropertyKey_AverageBitRate, (__bridge CFTypeRef)@(512 * 1024));
// 设置ProfileLevel为BP3.1
status = VTSessionSetProperty(_compressionSessionRef, kVTCompressionPropertyKey_ProfileLevel, kVTProfileLevel_H264_Baseline_3_1);
// 设置实时编码输出(避免延迟)
status = VTSessionSetProperty(_compressionSessionRef, kVTCompressionPropertyKey_RealTime, kCFBooleanTrue);
// 配置是否产生B帧
status = VTSessionSetProperty(_compressionSessionRef, kVTCompressionPropertyKey_AllowFrameReordering, self.videoEncodeParam.allowFrameReordering ? kCFBooleanTrue : kCFBooleanFalse);
// 配置最大I帧间隔  15帧 x 240秒 = 3600帧,也就是每隔3600帧编一个I帧
status = VTSessionSetProperty(_compressionSessionRef, kVTCompressionPropertyKey_MaxKeyFrameInterval, (__bridge CFTypeRef)@(self.videoEncodeParam.frameRate * self.videoEncodeParam.maxKeyFrameInterval));
// 配置I帧持续时间,240秒编一个I帧
status = VTSessionSetProperty(_compressionSessionRef, kVTCompressionPropertyKey_MaxKeyFrameIntervalDuration, (__bridge CFTypeRef)@(self.videoEncodeParam.maxKeyFrameInterval));
// 编码器准备编码
status = VTCompressionSessionPrepareToEncodeFrames(_compressionSessionRef);
3、输入待编码的视频数据

向编码器输送待编码的视频数据,通过调用VTCompressionSessionEncodeFrame方法实现。

VTCompressionSessionEncodeFrame(
    CM_NONNULL VTCompressionSessionRef  session,
    CM_NONNULL CVImageBufferRef         imageBuffer,
    CMTime                              presentationTimeStamp,
    CMTime                              duration, // may be kCMTimeInvalid
    CM_NULLABLE CFDictionaryRef         frameProperties,
    void * CM_NULLABLE                  sourceFrameRefCon,
    VTEncodeInfoFlags * CM_NULLABLE     infoFlagsOut )

session:创建编码器时的句柄
imageBuffer:YUV数据,iOS通过摄像头采集出来的视频流数据类型是CMSampleBufferRef,要从里面拿到CVImageBufferRef来进行编码。通过CMSampleBufferGetImageBuffer方法可以从sampleBuffer中获得imageBuffer。
presentationTimeStamp:这一帧的时间戳,单位是毫秒
duration:这一帧的持续时间,如果没有持续时间,填kCMTimeInvalid
frameProperties:指定这一帧的属性,这里可以用来指定产生I帧
encodeParams:自定义指针
infoFlagsOut:用于接收编码操作的信息,不需要就置为NULL

// 获取CVImageBufferRef
CVImageBufferRef imageBuffer = (CVImageBufferRef)CMSampleBufferGetImageBuffer(sampleBuffer);
// 设置是否为I帧
NSDictionary *frameProperties = @{(__bridge NSString *)kVTEncodeFrameOptionKey_ForceKeyFrame: @(forceKeyFrame)};;
// 输入待编码数据
OSStatus status = VTCompressionSessionEncodeFrame(_compressionSessionRef, imageBuffer, kCMTimeInvalid, kCMTimeInvalid, (__bridge CFDictionaryRef)frameProperties, NULL, NULL);
4、获取编码后的数据并进行处理

编码后的数据通过VTCompressionSessionCreate方法设置的回调函数返回。编码后的数据以及这一帧的基本信息都在CMSampleBufferRef中。如果这一帧是个关键帧,那么需要获取SPS和PPS数据,然后给这些数据加个开始码返回出去。

VEVideoEncoder *encoder = (__bridge VEVideoEncoder *)outputCallbackRefCon;
// 开始码
const char header[] = "\x00\x00\x00\x01";
size_t headerLen = (sizeof header) - 1;
NSData *headerData = [NSData dataWithBytes:header length:headerLen];
 
// 判断是否是关键帧
bool isKeyFrame = !CFDictionaryContainsKey((CFDictionaryRef)CFArrayGetValueAtIndex(CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, true), 0), (const void *)kCMSampleAttachmentKey_NotSync);
 
if (isKeyFrame)
{
    NSLog(@"VEVideoEncoder::编码了一个关键帧");
    CMFormatDescriptionRef formatDescriptionRef = CMSampleBufferGetFormatDescription(sampleBuffer);
    
    // 关键帧需要加上SPS、PPS信息
    size_t sParameterSetSize, sParameterSetCount;
    const uint8_t *sParameterSet;
    OSStatus spsStatus = CMVideoFormatDescriptionGetH264ParameterSetAtIndex(formatDescriptionRef, 0, &sParameterSet, &sParameterSetSize, &sParameterSetCount, 0);
    
    size_t pParameterSetSize, pParameterSetCount;
    const uint8_t *pParameterSet;
    OSStatus ppsStatus = CMVideoFormatDescriptionGetH264ParameterSetAtIndex(formatDescriptionRef, 1, &pParameterSet, &pParameterSetSize, &pParameterSetCount, 0);
    
    if (noErr == spsStatus && noErr == ppsStatus)
    {
        // sps数据加上开始码组成NALU
        NSData *sps = [NSData dataWithBytes:sParameterSet length:sParameterSetSize];
        NSMutableData *spsData = [NSMutableData data];
        [spsData appendData:headerData];
        [spsData appendData:sps];
        // 通过代理回调给上层
        if ([encoder.delegate respondsToSelector:@selector(videoEncodeOutputDataCallback:isKeyFrame:)])
        {
            [encoder.delegate videoEncodeOutputDataCallback:spsData isKeyFrame:isKeyFrame];
        }
        // pps数据加上开始码组成NALU
        NSData *pps = [NSData dataWithBytes:pParameterSet length:pParameterSetSize];
        NSMutableData *ppsData = [NSMutableData data];
        [ppsData appendData:headerData];
        [ppsData appendData:pps];
        
        if ([encoder.delegate respondsToSelector:@selector(videoEncodeOutputDataCallback:isKeyFrame:)])
        {
            [encoder.delegate videoEncodeOutputDataCallback:ppsData isKeyFrame:isKeyFrame];
        }
    }
}
// 获取帧数据
CMBlockBufferRef blockBuffer = CMSampleBufferGetDataBuffer(sampleBuffer);
size_t length, totalLength;
char *dataPointer;
status = CMBlockBufferGetDataPointer(blockBuffer, 0, &length, &totalLength, &dataPointer);
if (noErr != status)
{
    NSLog(@"VEVideoEncoder::CMBlockBufferGetDataPointer Error : %d!", (int)status);
    return;
}
 
size_t bufferOffset = 0;
static const int avcHeaderLength = 4;
while (bufferOffset < totalLength - avcHeaderLength)
{
    // 读取 NAL 单元长度
    uint32_t nalUnitLength = 0;
    memcpy(&nalUnitLength, dataPointer + bufferOffset, avcHeaderLength);
    
    // 大端转小端
    nalUnitLength = CFSwapInt32BigToHost(nalUnitLength);
    
    NSData *frameData = [[NSData alloc] initWithBytes:(dataPointer + bufferOffset + avcHeaderLength) length:nalUnitLength];
    
    NSMutableData *outputFrameData = [NSMutableData data];
    [outputFrameData appendData:headerData];
    [outputFrameData appendData:frameData];
    
    bufferOffset += avcHeaderLength + nalUnitLength;
    
    if ([encoder.delegate respondsToSelector:@selector(videoEncodeOutputDataCallback:isKeyFrame:)])
    {
        [encoder.delegate videoEncodeOutputDataCallback:outputFrameData isKeyFrame:isKeyFrame];
    }
}
5、停止编码
OSStatus status = VTCompressionSessionCompleteFrames(_compressionSessionRef, kCMTimeInvalid);
6、释放编码器
VTCompressionSessionInvalidate(_compressionSessionRef);
CFRelease(_compressionSessionRef);
_compressionSessionRef = NULL;

踩坑及总结

在弄视频编解码的时候,发现720P的分辨率,码率1Mbps,在画面晃动的时候马赛克很严重,码率设置的再低一点更严重。一开始以为是编码器的某些属性漏了设置了,或者是参数设置错了。查阅了很多资料都找不到原因。后来怀疑是ABR模式当画面从静止到晃动码率一下子上不去,导致马赛克,这个假设似乎成立,结果去打印编码出来的码率,画面晃动的时候码率是有上去的,说明这个思路还是不对。后来,发现,摄像头采集的数据是720P,也就是1280x720的分辨率,给编码器设置编码宽高的时候也是按1280x720的宽高设给编码器的,但实际上解码、播放是展示的画面尺寸(像素)只有320x180,于是尝试了一下把编码的宽高设置为320x180,马赛克问题解决了!

posted @ 2021-03-02 06:02  吴建明wujianming  阅读(720)  评论(0编辑  收藏  举报