Android 音视频开发【视频篇】【三】视频编码 | 8月更文挑战

1,040 阅读8分钟

这是我参与8月更文挑战的第3天,活动详情查看:8月更文挑战

前两章我们介绍了视频的RGB、YUV原始格式,以及如何对摄像头数据进行采集,本章将介绍如何编码YUV数据

一、视频编码

在前一章,介绍了如何对摄像头数据进行采集,尝试过的朋友可能会发现,采集的NV21数据,几秒钟下来,文件就上百M了,我们平时看的MP4,几秒也才几M,这是怎么回事呢

这是因为我们存储的是原始格式,是未进行压缩的数据,所以存储下来是特别大

其实不光是存储大,一般原始格式的视频,也是无法播放的,因为播放无法知晓你的一大堆数据是干什么的,播放器一般得知道视频的帧率、分辨率、码率等一些参数,才能正确的解析视频并进行解码播放

所以接下来我们先来编码摄像头采集的数据

常见的编码格式有H26x,一般Mp4的视频编码格式是H264,这种编码格式能够大大的减少存储大小

回顾一下上一章的视频采集过程:

  1. 打开摄像头
  2. 初始化摄像头
  3. 设置预览回调
  4. 开始预览
  5. 在预览回调中,获取NV21数据,并写入文件中
  6. 停止预览,释放相机

那么对于编码的话,需要在第5步进行修改,加入编码的逻辑,步骤变为:

  1. 打开摄像头
  2. 初始化摄像头
  3. 设置预览回调
  4. 开始预览
  5. 在预览回调中,获取NV21数据,转换成NV12数据,传入编码器
  6. 在编码器输出中,获取编码后的数据,写入文件或者MediaMuxer
  7. 停止预览,释放相机

可以注意到,我们再预览回调中,获取的是NV21的数据,而在传入编码器时,需要的是NV12的数据(编码器对NV12格式的数据支持最好),所有中间有个转换的过程,关于这个转换,我们下面会有详细的讲解

还有一个是,在第6步中,我们有两个选择,如果需要使我们编码后的视频能够在任意播放器中播放出来,那么可以使用MediaMuxer将其封装成Mp4

那么,接下来我们就上述进行具体的实现

二、YUV旋转、镜像

2.1 YUV旋转

在预览回调中,我们获取到了原始的NV21数据,不过需要的是注意,此时的图像是被旋转过的,及时我们在初始化摄像头时对其角度进行了旋转,预览画面也显示正常,但预览回调还是被选中90270度的,所有我们在将其转换成NV12时,还需注意旋转角度问题

旋转角度

我们在初始化摄像头的时候,设置了旋转角度

camera.setDisplayOrientation(orientation = CameraUtils.getDisplayOrientation(activity, facing));

因为在NV21NV12时需要用到,所以这里我们用一个变量保存起来

下面我们来进行各种角度的旋转

旋转90度

首先,假设图像是这样的

NV21原图像.png

接着顺时针旋转90度,并转换成NV12的格式,可以得到

转换成NV12并旋转90度.png

我们先来看Y的数据

NV12第一行取的是NV21第一列的数据,第二行取的是第二列的数据,依次类推,就得到了Y的数据

再来看UV的数据,因为NV12NV21YUV420sp格式的数据,所以需要将UV看做一个整体进行旋转

NV12第一第二个数据取的是U3V3,本来存放顺序是V3->U3的,但因为NV12UV顺序和NV21UV顺序刚好相反,所以需要将二者倒过来,依次类推,就得到了上图的数据存储形式 接下来看代码如何实现

/**
 * nv21转nv12,并且旋转90度
 */
private static byte[] nv21ToNv12AndRotate90(byte[] inputData, int width, int height) {
    if (inputData == null) {
        return null;
    }
    int size = inputData.length;
    if (size != (width * height * 3 / 2)) {
        return null;
    }
    byte[] outputData = new byte[size];
    int k = 0;
    for (int i = 0; i < width; i++) {
        for (int j = height - 1; j >= 0; j--) {
            outputData[k++] = inputData[width * j + i];
        }
    }
    int start = width * height;
    for (int i = 0; i < width; i += 2) {
        for (int j = height / 2 - 1; j >= 0; j--) {
            outputData[k++] = inputData[start + width * j + i + 1];
            outputData[k++] = inputData[start + width * j + i];
        }
    }
    return outputData;
}

代码中,有两个for循环,第一个是处理Y数据,而第二个是处理UV数据

从上面的图示和代码中,我们不难发现旋转其他角度的规律

旋转180度

/**
 * nv21转nv12,并且旋转180度
 */
private static byte[] nv21ToNv12AndRotate180(byte[] inputData, int width, int height) {
    if (inputData == null) {
        return null;
    }
    int size = inputData.length;
    if (size != (width * height * 3 / 2)) {
        return null;
    }
    byte[] outputData = new byte[size];
    int k = 0;
    for (int i = height - 1; i >= 0; i--) {
        for (int j = width - 1; j >= 0; j--) {
            outputData[k++] = inputData[width * i + j];
        }
    }
    int start = width * height;
    for (int i = height / 2 - 1; i >= 0; i--) {
        for (int j = width - 1; j >= 0; j -= 2) {
            outputData[k++] = inputData[start + width * i + j];
            outputData[k++] = inputData[start + width * i + j - 1];
        }
    }
    return outputData;
}

旋转270度

/**
 * nv21转nv12,并且旋转270度
 */
private static byte[] nv21ToNv12AndRotate270(byte[] inputData, int width, int height) {
    if (inputData == null) {
        return null;
    }
    int size = inputData.length;
    if (size != (width * height * 3 / 2)) {
        return null;
    }
    byte[] outputData = new byte[size];
    int k = 0;
    for (int i = width - 1; i >= 0; i--) {
        for (int j = 0; j < height; j++) {
            outputData[k++] = inputData[width * j + i];
        }
    }
    int start = width * height;
    for (int i = width - 1; i >= 0; i -= 2) {
        for (int j = 0; j < height / 2; j++) {
            outputData[k++] = inputData[start + width * j + i];
            outputData[k++] = inputData[start + width * j + i - 1];
        }
    }
    return outputData;
}

以上,就包括了相机旋转的所有角度,不过,还有一点需要注意,一般,前摄的图像是相反的,也就是说,我们在处理前摄时,还需要对其进行镜像处理

2.2 YUV镜像

/**
 * 镜像处理
 */
private static byte[] yuvMirror(byte[] inputData, int width, int height) {
    if (inputData == null) {
        return null;
    }
    int size = inputData.length;
    byte[] outputData = new byte[size];
    int k = 0;
    for (int i = 0; i < height; i++) {
        for (int j = width - 1; j >= 0; j--) {
            outputData[k++] = inputData[width * i + j];
        }
    }
    int start = width * height;
    for (int i = 0; i < height / 2; i++) {
        for (int j = width - 1; j >= 0; j -= 2) {
            outputData[k++] = inputData[start + width * i + j - 1];
            outputData[k++] = inputData[start + width * i + j];
        }
    }
    return outputData;
}

镜像处理比较简单,就是将每行的数据倒序存放,但需要注意UV是个整体,在处理的时候也不要忘记

我们再来做一个方法,根据传入的数据、尺寸、摄像头类型、旋转角度来做处理

/**
 * nv21转换nv12
 */
public static byte[] cameraNv21ToNv12(
        byte[] data,
        int width,
        int height,
        int facing,
        int orientation) {
    byte[] outputData;
    Log.d(TAG, "cameraNv21ToNv12: " + orientation + " facing:" + facing);
    int rotate = orientation;
    int w = width;
    int h = height;
    if (facing == Camera.CameraInfo.CAMERA_FACING_FRONT) {
        rotate = 360 - orientation;
    }
    switch (rotate) {
        case 90:
            // 经过旋转,宽高互换
            w = height;
            h = width;
            outputData = nv21ToNv12AndRotate90(data, width, height);
            break;
        case 180:
            // 经过旋转,宽高不变
            outputData = nv21ToNv12AndRotate180(data, width, height);
            break;
        case 270:
            // 经过旋转,宽高互换
            w = height;
            h = width;
            outputData = nv21ToNv12AndRotate270(data, width, height);
            break;
        default:
            outputData = data;
            break;
    }
    return cameraNv21ToNv12WidthFacing(outputData, w, h, facing);
}
/**
 * 通过facing获取nv12数据
 */
private static byte[] cameraNv21ToNv12WidthFacing(
        byte[] data,
        int width,
        int height,
        int facing) {
    if (facing == Camera.CameraInfo.CAMERA_FACING_FRONT) {
        // 前摄是镜像的,所有需要做一次镜像处理
        return yuvMirror(data, width, height);
    }
    return data;
}

从上面可以看出,我们只暴露了一个cameraNv21ToNv12()方法给外部调用

三、MediaCodec编码

在学习了如何对YUV进行编码前预处理后,下面就开始真正的编码

初始化MediaCodec

private void initMediaCodec() {
    int width = this.height;
    int height = this.width;
    try {
        MediaFormat format = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, width, height);
        format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar);
        format.setInteger(MediaFormat.KEY_FRAME_RATE, 30);
        format.setInteger(MediaFormat.KEY_BIT_RATE,
                width * height * 4);
        format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1);
        //设置压缩等级  默认是baseline
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            format.setInteger(MediaFormat.KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileMain);
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                format.setInteger(MediaFormat.KEY_LEVEL, MediaCodecInfo.CodecProfileLevel.AVCLevel3);
            }
        }
        mediaCodec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC);
        mediaCodec.setCallback(this);
        mediaCodec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
        mediaCodec.start();
    } catch (IOException e) {
        e.printStackTrace();
    }
}

对于MediaCodec的使用,我们在前面音频编码已经有一定了解,不过之前我们都是使用的MediaCodec同步编码方式,下面我们将使用MediaCodec异步编码方式

MediaCodecsetCallback后,需要实现几个回调方法

  • onInputBufferAvailable()

    输入buffer可用时回调,此时可以在此插入数据

  • onOutputBufferAvailable()

    在编码数据完成时回调,此时的数据是已经编码的H264数据,可以将此数据写入MediaMuxer

  • onError()

    发生错误时返回

  • onOutputFormatChanged()

    在此处,可以对输出的MediaFormat处理,比如调用MediaMuxer.addTrack方法得到一个TrackId,用于写入MediaMuxer

在初始化MediaCodec后,我们可以对MediaMuxer进行初始化,MediaMuxer可以帮助我们生成Mp4,方便我们查看视频是否编码正常

private void initMediaMuxer(String path) {
    if (TextUtils.isEmpty(path)) {
        return;
    }
    File file = new File(path);
    if (file.exists()) {
        file.delete();
    }
    try {
        mediaMuxer = new MediaMuxer(path, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);
    } catch (IOException e) {
        e.printStackTrace();
        mediaMuxer = null;
    }
}

从上面的描述来看,我们需要将摄像头和编码的操作放入子线程,而摄像头的预览数据回调和编码插入取出数据又都是异步的,那么我们应该怎么办呢

其实我们可以使用一个队列,存放预览回调的数据,然后在编码的时候,将队列的数据取出插入编码器,这样就可以达到想要的效果

对于队列,我们可以使用LinkedBlockingQueue,它是一个线程安全的队列

private final BlockingQueue<byte[]> queue = new LinkedBlockingQueue<>(10);

预览数据处理

@Override
public void onPreviewFrame(byte[] data, Camera camera) {
    if (this.data == null) {
        this.data = new byte[width * height * 3 / 2];
    }
    camera.addCallbackBuffer(this.data);
    queue.offer(YuvUtils.cameraNv21ToNv12(this.data, width, height, facing, orientation));
}

在预览回调中,我们调用了之前所介绍的方法cameraNv21ToNv12nv21数据转换成nv12数据,并将其传入队列中

插入编码器

@Override
public void onInputBufferAvailable(@NonNull MediaCodec codec, int index) {
    ByteBuffer buffer = codec.getInputBuffer(index);
    buffer.clear();
    int size = 0;
    byte[] data = queue.poll();
    if (data != null) {
        buffer.put(data);
        size = data.length;
    }
    codec.queueInputBuffer(index, 0, size, System.nanoTime() / 1000, 0);
}

onInputBufferAvailable()回调中,我们取出数据,并插入编码器中,注意,插入时需要传入时间戳,我们传入System.nanoTime() / 1000即可

添加视频轨道

在写入MediaMuxer前,我们还得先将视频的输出格式写入MediaMuxer,这样才能回去一个TrackId供我们写入数据

@Override
public void onOutputFormatChanged(@NonNull MediaCodec codec, @NonNull MediaFormat format) {
    if (trackIndex != -1) {
        return;
    }
    if (mediaMuxer == null) {
        return;
    }
    trackIndex = mediaMuxer.addTrack(format);
    mediaMuxer.start();
}

获取编码数据,写入MediaMuxer

@Override
public void onOutputBufferAvailable(@NonNull MediaCodec codec, int index, @NonNull MediaCodec.BufferInfo info) {
    ByteBuffer buffer = codec.getOutputBuffer(index);
    if (buffer != null && info.size > 0) {
        if (mediaMuxer != null && trackIndex != -1) {
            mediaMuxer.writeSampleData(trackIndex, buffer, info);
        }
        buffer.clear();
    }
    codec.releaseOutputBuffer(index, false);
}

这样,我们就完成了整个视频采集到编码,最后到输出Mp4的过程

最后,别忘了释放相关资源

private void closeCamera() {
    if (camera == null) {
        return;
    }
    camera.stopPreview();
    camera.release();
    camera = null;
}
​
private void stopMediaMuxer() {
    if (mediaMuxer == null) {
        return;
    }
    mediaMuxer.stop();
    mediaMuxer.release();
    mediaMuxer = null;
}
​
private void stopMediaCodec() {
    if (mediaCodec == null) {
        return;
    }
    mediaCodec.stop();
    mediaCodec.release();
    mediaCodec = null;
}

四、GitHub

YuvEncoder.java

YuvActivity.java