(译)视频录制开发 - 打造视频版Instagram

原文: Video Recording App Development: How We Built Instagram for Videos

我没想冒犯谁,但必须承认,打造移动应用时,有两类用户是我们需要关注的:控制狂和小绵羊。比如对于一个视频录制和编辑APP,前者就想当自己作品的造物主。他们喜欢按自己的方式行事,希望完全自由录制和编辑视频。而后者偏好简单的操作,并不想一帧一帧的处理视频。他们觉得这样很麻烦,只想按几个按钮就得到一个作品。

GooglePlay上有很多视频应用,我们以刚才说的理论分析下他们的目标用户。比如Magisto,有只需在相册中选好照片和视频,然后风格(舞蹈,爱心,时尚,等等),最后选择配音。魔法一般,你的作品完成了。参考:Case study: Mobile technologies to develop Periscope

你得到了一个满意的作品,但对视频的生成没什么控制权。而且,你还需要等大约两分钟才能生成视频,这是多么漫长。

而且你获得的是一个播放地址,而不是存在本地的文件。如果你想下载视频,就得付费办个会员。

Magisto在其所属的类别里,算是一个很棒的应用,对于小绵羊类型的用户来说,几乎完美。然而,我几乎找不到可以推荐给控制狂用户的安卓APP。为什么?因为大部分在客户端处理视频的视频社交APP,处理性能都不好。但你不应该责怪开发APP的人。关键在于,即使我们是安卓的忠粉,但视频的确不是它的强项。这就是为什么,GooglePlay上很难找到惊艳的视频社交APP。

且慢!

Yalantis正在开发的一款APP有望填补这个空缺。我完全可以告诉你开发过程中遇到的种种困难,但是在我们讨论技术之前,你可以先看下这篇 文章,关于视频录制和编辑APP,以及市场上的最佳表现者。

Android 的问题是什么

我很诚实的告诉你,开发一款视频录制的Android APP是很挑战性的任务。使用标准的MediaRecorder类并不难,我们要讲的是更高级的视频录制功能。
借助苹果提供的AVFoundation这个原生工具,开发者可以实现各种视频功能,但是谷歌不知怎么在这一点落后了。
尽管有困难,但我们的确想做一款视频的Instagram,所以我们接受了挑战。

视频录制最酷的功能有哪些

在项目中,我们要满足以下要求:

  1. 在15秒内录制几个视频段
  2. “快动作” – 以10帧每秒的速度录制延时视频
  3. “慢动作” – 以120帧每秒录制视频
  4. “定格动画” – 录制只包含2~3帧的极短视频
  5. 将已录制的几个视频段合成一个文件
  6. 在视频中循环播放音乐
  7. 倒着播视频
  8. 裁剪视频以便上传到Fcebook和Instagram等网站
  9. 加水印

从哪里入手

我们先尝试用MediaRecorder,Android上实现视频录制最简单的方法。尽管MediaRecorder多年来一直被开发者采用,支持所有版本的安卓系统,但是却无法自定义。

在4.3以及之前的版本,视频的实现能力仍是有限的。在Camera类和MediaRecorder类的帮助下,你可以用手机的相机录制视频,应用标准的相机颜色滤镜(深褐色, 黑色 ,白色,等等),差不多就这些了。而且, MediaRecorder的录制有700 毫秒的延迟,但是录制小段视频时,近乎1秒的延迟是不可接受的。

然而,安卓系统还是给力了一会。从 Jelly Bean(API 4.1)开始,谷歌增加了 MediaCodec类和 MediaExtractor类。前者允许你接触底层的多媒体编解码(比如编码组件和解码组件),后者助你从数据源抽取已编码的多媒体信息。

幸运的是,安卓4.3引入了MediaMuxer 类,支持原始视频流和音频流的合成(多路复用,将多个媒体流合成到一个文件)。这个组件为视频开发增加了新的可能性:你可以编码/解码视频流,也可以做一些处理。

尽管这是个艰难的决定,但我们的APP只打算支持安卓4.3以上,以便能够使用MediaCodec和MediaMuxer来录制视频。而且,录制初始化时的延迟也可以避免了。

都有哪些技术栈

在正式开发前,我们需要弄明白MediaCodec是怎么工作的。我们对Google提供的例子做了研究。 Grafika展示了多种hack方式来帮助你以新的方案录制和编辑视频。

干货来了。我们使用了以下技术:

  1. 用OpenGL来渲染获取的帧。shader可以编辑帧, 也能用于在相机预览界面快速的绘制帧.
  2. 用MediaCodec来录制视频
  3. 用FFmpeg来处理视频
  4. 用Mp4parser来合成视频段
  5. 用MediaRcorder来实现“快动作”模式

如何使用FFmpeg库

FFmpeg的使用并不简单。这是一个开源的跨平台的库。在移动端视频和图片处理领域,它是非常强大的。它是用C写的,也有很多插件。
FFmpeg主要的问题在于需要编译大量的插件并把他们添加到项目中。这个过程要花费大量的时间和开发成本,这会让我们的客户(即委托Yalantis开发的客户,译者注)很不高兴。因此我们决定以另一种方式解决这个问题。
我们购买了FFmpeg4Android的license,这是一个现成的安卓库。但是即便我们妥协了这么多,仍有一些重要的事项要处理。

FFmpeg特殊的一点是,你需要在命令字符串中,像运行可执行文件一样运行它。换句话说,你需要传入一个命令字符串,包含入参和需要应用到最终的视频的一些参数。主要的问题在于,出错时你无法debug。你只能从记录FFmpeg操作的log文件中获得信息。需要花些时间才能弄明白各个命令是怎么工作的,以及如何组合一个复杂的命令以便同时执行多个操作。

我们坚定的决定集成一个我们自己的ffmpeg库,并且封装了一下以便能debug APP。

实现视频APP的功能

慢动作

慢动作是我们要开发的APP里优先级很高的一个功能。不像IOS,几乎所有的安卓设备都没有硬件上的支持来实现慢动作所要达到的帧率。安卓系统也无法提供一个开关来“启用”只有很小部分的手机上才有的慢动作硬件支持。

然而,只要肯钻研,办法还是有的。我们可以通过软件层实现。下面是实现的方法:
方法1. 录制的时候重复帧,或者延长时间(显示一帧的时间)
方法2. 录制的帧率不变,处理时重复每一帧。

我们实现了这个方法,但说实话,效果并不好。如果你无法做好一件事,那就不要去做这件事。我们打算延迟这个功能的实现,直到有更好的条件。
iPhone上慢动作的效果:https://youtu.be/FTufIObL1Kc

快动作

安卓上实现延时视频并不难。MediaRecorder能够设置帧率,比如10帧每秒(标准的视频录制的帧率是30帧每秒)。这样,每三帧取一帧,视频就是以三倍的速度播放了。

private boolean prepareMediaRecorder() {
        if (camera == null) {
            return false;
        }
        camera.unlock();
        if (mediaRecorder == null) {
            mediaRecorder = new MediaRecorder();
            mediaRecorder.setCamera(camera);
        }

        mediaRecorder.setVideoSource(MediaRecorder.VideoSource.CAMERA);
        mediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4);

        CamcorderProfile profile = getCamcorderProfile(cameraId);
        mediaRecorder.setCaptureRate(10);
        mediaRecorder.setVideoSize(profile.videoFrameWidth, profile.videoFrameHeight);
        mediaRecorder.setVideoFrameRate(30);
        mediaRecorder.setVideoEncodingBitRate(profile.videoBitRate);
        mediaRecorder.setOutputFile(createVideoFile().getPath());
        mediaRecorder.setPreviewDisplay(cameraPreview.getHolder().getSurface());
        mediaRecorder.setVideoEncoder(MediaRecorder.VideoEncoder.H264);

        try {
            mediaRecorder.prepare();
        } catch (Exception e) {
            releaseMediaRecorder();
            return false;
        }

        return true;
    }

效果:https://youtu.be/YA4ylL8r2LU?t=2

定格动画

MediaRecorder不适合即时的帧数少的录制。就像已经提到过的,它在开始录制时有较长的延迟。但是MediaCodec能够解决这个问题。
效果:https://youtu.be/P7dvoDmrFiA

将多个视频段合成为一个文件

这是我们的APP的主功能之一。录制了几个视频片段后,用户想合成一个文件。

刚开始,我们使用的是FFmpeg,但又否决了。因为它在合成视频时需要转码,这会增加处理时间。在Nexus 5上,用7~8个视频片段合成一个15秒左右的视频,需要15秒。视频片段有100多个时,合成要1分多钟。如果你设置了较高的比特率,或者效果更好的编解码器,花费的时间会更长。

因此,我们选择了mp4parser, 这个库是这样工作的:
它从多个文件中拉取已编码的数据,然后再创建一个空文件,连贯的将拉取的数据传入这个文件。然后在头部写入一些信息,这样就得到了单个的视频文件。

mp4parser唯一的缺点是,所有的视频片段需要以同样的参数编码(编解码类型,后缀名,长宽比)。根据所需合成的片段数不同,mp4parser处理视频的时间是1~4秒。

下面是mp4parser合成视频的一个例子:

public void merge(List<File> parts, File outFile) {
  try {
    Movie finalMovie = new Movie();
    Track[] tracks = new Track[parts.size()];
    for (int i = 0; i < parts.size(); i++) {
      Movie movie = MovieCreator.build(parts.get(i).getPath());
      tracks[i] = movie.getTracks().get(0);
    }
    finalMovie.addTrack(new AppendTrack(tracks));
    FileOutputStream fos = new FileOutputStream(outFile);
    BasicContainer container = (BasicContainer) new DefaultMp4Builder().build(finalMovie);
    container.writeContainer(fos.getChannel());
  } catch (IOException e) {
    Log.e(TAG, "Merge failed", e);
  }
}

倒着播视频

我们使用了下面的步骤来倒序视频

  1. 从视频文件中抽取所有的帧,将它们写入内部存储(比如jpg文件)。
  2. 重命名帧以便倒序排列帧。
  3. 将上一步的文件合成为一个视频文件。

下面是将视频文件以帧为单位分成小文件的例子:

ffmpeg -y -i input.mp4 -strict experimental -r 30 -qscale 1 -f image2 -vcodec mjpeg %03d.jpg

然后你需要重命名这些帧文件来实现倒序。(比如第一帧成为了最后一帧,第二帧成为了倒数第二帧)

然后,下面的这条命令能让帧文件合成为视频

ffmpeg -y -f image2 -i %03d.jpg -r 30 -vcodec mpeg4 -b:v 2100k output.mp4

这个解决方案既不高雅也不高效,性能也慢。因为我们不想让用户等待,所以暂时不加入这个功能,以后的版本有可能。

效果:https://youtu.be/WMlkrgCwgbA

Gif

Gif基本就是使用少量的帧来创建视频,循环播放时就有了Gif的效果了。Instagram刚发布了Boomerang,就是处理此类Gif的。

实现Gif比较简单—以一定的间隔(我们用的是125ms)拍摄6张照片,并以倒序重复帧(不包括首帧和最后一帧)以形成流畅的倒放效果,然后通过FFmpeg将这些帧合成为一个视频。

ffmpeg -y -f image2 -i %02d.jpg -r 15 -filter:v setpts=2.5*PTS -vcodec libx264 output.mp4
  • f 表示输入文件的设置
  • i %02d.jpg 表示输入文件名的格式(01.jpg, 02.jpg等等)
  • filter:v setpts=2.5PTS 每一帧的停留时间为原先的2.5倍(译者注:也就是降低播放速度)
    为了优化用户体验,我们目前在保存和分享文件的阶段创建视频,这样用户就不需要等待视频处理了。在这之前,我们会处理存放在RAM上的照片,然后在 TextureView 的Canvas上绘制。
private long drawGif(long startTime) {
        Canvas canvas = null;
        try {
            if (currentFrame >= gif.getFramesCount()) {
                currentFrame = 0;
            }
            Bitmap bitmap = gif.getFrame(currentFrame++);
            if (bitmap == null) {
                handler.notifyError();
                return startTime;
            }

            destRect(frameRect, bitmap.getWidth(), bitmap.getHeight());

            canvas = lockCanvas();

            canvas.drawBitmap(bitmap, null, frameRect, framePaint);

            handler.notifyFrameAvailable();

            if (showFps) {
                canvas.drawBitmap(overlayBitmap, 0, 0, null);
                frameCounter++;
                if ((System.currentTimeMillis() - startTime) >= 1000) {
                    makeFpsOverlay(String.valueOf(frameCounter) + "fps");
                    frameCounter = 0;
                    startTime = System.currentTimeMillis();
                }
            }
        } catch (Exception e) {
            Timber.e(e, "drawGif failed");
        } finally {
            if (canvas != null) {
                unlockCanvasAndPost(canvas);
            }
        }

        return startTime;
    }
public class GifViewThread extends Thread {

        public void run() {
            long startTime = System.currentTimeMillis();
            try {
                if (isPlaying()) {
                    gif.initFrames();
                }
            } catch (Exception e) {
                Timber.e(e, "initFrames failed");
            } finally {
                Timber.d("Loading bitmaps in " + (System.currentTimeMillis() - startTime) + "ms");
            }
            long drawTime = 0;
            while (running) {
                if (paused) {
                    try {
                        Thread.sleep(10);
                    } catch (InterruptedException ignored) {}
                    continue;
                }
                if (surfaceDone && (System.currentTimeMillis() - drawTime) > FRAME_RATE) {
                    startTime = drawGif(startTime);
                    drawTime = System.currentTimeMillis();
                }
            }
        }
    }

效果:https://youtu.be/ZiHdOYAk15U

剩下的功能

我们使用FFmpeg实现了余下的功能,比如在视频中循环播放音乐,裁剪视频,添加水印以便将视频上传到Instagram,Facebook等社交平台。

我开发这个应用已经差不多9个月了,我的体会是,如果你掌握了一些使用经验,FFmpeg使用起来还是比较简单的。比如,在视频中循环播放音乐的命令如下:

ffmpeg -y -ss 00:00:00.00 -t 00:00:02.88 -i input.mp4 -ss 00:00:00.00 -t 00:00:02.88 -i tune.mp3 -map 0:v:0 -map 1:a:0 -vcodec copy -r 30 -b:v 2100k -acodec aac -strict experimental -b:a 48k -ar 44100 output.mp4
  • y 表示不需要请求就可以重写文件
  • ss 00:00:00.00 从哪个时间戳开始处理视频
  • t 00:00:02.88 停止处理视频的时间戳
  • i input.mp4 输入视频的文件名
  • i tune.mp3 输入音频的文件名
  • map 视频和音频通道的映射
  • vcodec 视频编解码的设置(这里我们使用跟输入视频相同的编解码)
  • r 帧率
  • b:v 视频通道的比特率
  • acodec 音频编解码的设置(这里我们使用AAC)
  • ar 音频通道的采样率
  • b:a 音频通道的比特率
  • output.mp4 生成的文件

加水印和视频裁剪的命令:

ffmpeg -y -i input.mp4 -strict experimental -r 30 -vf movie=watermark.png, scale=1280*0.1094:720*0.1028 [watermark]; [in][watermark] overlay=main_w-overlay_w:main_h-overlay_h, crop=in_w:in_w:0:in_h*in_h/2 [out] -b:v 2100k -vcodec mpeg4 -acodec copy output.mp4
  • movie=watermark.png 水印图片的位置
  • scale=1280 * 0.1094:720 * 0.1028 水印的尺寸
  • [in][watermark] overlay=main_w-overlay_w:main_h-overlay_h, crop=in_w:in_w:0:in_h*in_h/2 [out] 添加水印和裁剪视频

我们开发这个视频录制APP已经好久了,也有了充分的知识技能来实现更多的视频录制功能。下面是我们有待实现的功能:

  1. 封装我们自己的FFmpeg库并在JNI层调用,不仅提高性能,增加了灵活性,也减小了库大小(我们的项目并不需要全部的FFmpeg模块)
  2. 录制视频和Gif时可以应用我们自己的滤镜

当年在阅读这篇文章时,我们离目标又近了一步。
其他参考文章:
How to build photo sharing apps like Instagram

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 155,978评论 24 681
  • 所谓的三合与六合,是五行(木、火、土、金、水)中的某几行力量的吸引和凝聚。六合好比夫妻之亲,三合则犹如母子之情。由...
    晓亮sz阅读 237评论 0 0
  • 患得患失看起来是自卑,说到底却是自己实力不足,却又想表现得完美。 以为自己喜欢独处,其实更喜欢群居。群居的时候又表...
    晴空万你阅读 33评论 0 0