高仿剪映视频多轨剪辑页实现

2020/08/04 08:20
阅读数 1.2K
剪映是当下比较火的一款手机视频剪辑工具,由抖音官方推出,可用于手机短视频的剪辑制作,拥有强大的多轨编辑能力。其中视频剪辑页用于剪辑的View拥有出色的交互性,很考验Android的基础能力,值得拿出来学习一下。
  观察剪映的视频剪辑页面,可见主要有 时间轴视频轨道时间游标预览窗口四部分组成。 时间轴用于展示当前的时间长度和时间刻度,通过缩放手势可以改变最小刻度值,拖动可以对音视频进行seek。 视频轨道用于显示轨道在时间轴上的长度、以及轨道信息,同时视频轨道会显示对应时间的帧图像,而音频轨道则会显示波形图。 时间游标会固定在整个View的中间位置,虽然叫它游标,但实际上并不会移动,只能通过移动时间轴和视频轨道来表示当前的时间位置。 预览窗口用于显示视频帧,通常是SurfaceView或TextureView,比较简单,非本文的重点。


实现

本文并不会完全通过Canvas绘制每一个UI元素,而是尽可能利用Android现有的View进行组合实现,虽然性能较低,但实现起来简单。整个View结构分三层:

  1. AlTrackContainer作为整个View的根,继承自HorizontalScrollView以实现水平滚动,同时负责缩放手势处理以及时间游标的绘制。

  2. AlTrackView负责组织时间轴和各个视频轨道的布局,同时响应缩放手势,实时改变子View的长度。

  3. AlTimelineView作为时间轴,负责绘制时间刻度,同时响应缩放手势,实时改变时间刻度和长度。

  4. AlTrackItemView单纯继承自TextView,用于显示轨道名称以及音频的波形。

时间轴
AlTimelineView由时间刻度和圆点组成,时间刻度格式为##:##,值得注意的是刻度与圆点之间有一个最小和最大间距,这里把刻度与圆点距离、最小和最大间距分别定义为Space、MinSpace和MaxSpace,Space总是大于MinSpace,小于MaxSpace,其中MaxSpace=MinSpace*4+圆点直径+刻度文字宽度,以便于Space>MaxSpace时,正好能够增加显示一个时间刻度。

  1. 根据View的宽度、##:##宽度以及Space与MinSpace、MaxSpace的关系初始化刻度值,并把每个刻度值的String保存到一个数组。

  2. 当通过缩放手势放大时间轴,刻度间距由小到大变化,直到Space>MaxSpace时,根据View的宽度、刻度宽度以及Space与MinSpace、MaxSpace的关系重新生成新的刻度,并覆盖保存到数组,如果计算得当的话,新的刻度Space总是大于MinSpace,小于MaxSpace。

  3. 同理,当通过缩放手势放大时间轴,直到Space<MinSpace时,重新计算刻度数组。不同于上面的放大逻辑,这里直接把刻度数量除以2,然后根据新的刻度数量重新计算间距,这样就能实现刻度间距由大到小的效果。

此时我们只需要在onDraw中根据Space把刻度数组里的文字、以及刻度之间的小圆点绘制出来即可。核心代码如下:

//放大的情况下保持最小刻度不变private fun keepZoomLevel(visibleWidth: Int): Int {    if (abs(mLastVisibleWidth - visibleWidth) < 5) {        return textVec.size    }    mLastVisibleWidth = visibleWidth    val tmp = (visibleWidth - textSize.x * textVec.size) / (textVec.size - 1).toFloat()    if (tmp < textSize.x + cursorRect.width() * 2 && tmp > cursorRect.width()) {        spaceSize = tmp        return textVec.size    }    return Int.MIN_VALUE}
private fun measureText(): Int { if (durationInUS <= 0) { textVec.clear() return 0 } //textSize.x为##:##的宽度,加textSize.x是为了保证##:##的宽度中间为该刻度值。 val visibleWidth = measuredWidth + textSize.x - paddingLeft - paddingRight var count = (visibleWidth / (textSize.x + cursorRect.width())).toInt() if (textVec.size == count) { return count } if (textVec.isNotEmpty()) { if (Int.MIN_VALUE != keepZoomLevel(visibleWidth)) { return textVec.size } count = if (count < textVec.size) { textVec.size / 2 } else { textVec.size * 2 } } textVec.clear() if (count > 1) { spaceSize = (visibleWidth - textSize.x * count) / (count - 1).toFloat() for (i in 0 until count) { textVec.add(fmt.format(Date(i * durationInUS / (count - 1) / 1000))) } } else { spaceSize = (visibleWidth - textSize.x).toFloat() textVec.add(fmt.format(Date(0))) } return count}
override fun onDraw(canvas: Canvas?) { super.onDraw(canvas) val count = measureText() for (i in 0 until count) { val text = textVec[i] val x = paddingLeft - textSize.x / 2f + ((textSize.x + spaceSize) * i).toFloat() canvas?.drawText(text, x, (measuredHeight + textSize.y) / 2f, paint) if (i < count - 1) { canvas?.drawCircle( x + textSize.x + spaceSize / 2f, measuredHeight / 2f, cursorSize / 2f, paint ) } }}


视频轨道
AlTrackItemViewAlTrackView进行布局,AlTrackView同时页负责时间轴的摆放,功能比较简单。只需要保证AlTimelineView和AlTrackItemView的垂直线性布局即可,同时需要保证AlTrackItemView在时间轴下的占比,并且在缩放的同时成比例改变AlTrackItemView和AlTrackView的宽度。
  首先 AlTrackView需要有一个缩放接口,该接口输入一个缩放比例,比例改变的同时在onMeasure方法内部根据缩放系数改变自身宽度。
fun setScale(scale: AlRational) {    this.scale.num = scale.num    this.scale.den = scale.den    requestLayout()}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) val width = MeasureSpec.getSize(widthMeasureSpec) val height = MeasureSpec.getSize(heightMeasureSpec) measureChildren(widthMeasureSpec, heightMeasureSpec) if (originWidth <= 0) { originWidth = width } setMeasuredDimension( originWidth * scale.num / scale.den + paddingLeft + paddingRight, height )}

而AlTimelineView则需要在AlTrackView初始化时进行添加。这里给AlTimelineView添加了一个上下的padding,让刻度与View的边缘保持一定间距。

constructor(context: Context, attrs: AttributeSet?, @AttrRes defStyleAttr: Int): super(context, attrs, defStyleAttr) {    onResolveAttribute(context, attrs, defStyleAttr, 0)    onInitialize(context)}
private fun onInitialize(context: Context) { clipToPadding = false mTimeView = AlTimelineView(context) mTimeView.setPadding( 0, applyDimension(TypedValue.COMPLEX_UNIT_DIP, 16f).toInt(), 0, applyDimension(TypedValue.COMPLEX_UNIT_DIP, 16f).toInt() ) addView(mTimeView, makeLayoutParams())}

同时AlTrackView需要有一个addTrack接口,支持外部添加不同的轨道。该接口会通过传入的轨道信息,生成对应的AlTrackItemView(TextView),同时把生成的View和轨道信息保存到不同的Map中,方便进行布局。updateAudioTrack用于根据音频轨道的文件路径生成音频波形的Bitmap,然后作为View的背景,音频波形图可以通过FFmpeg命令生成。

fun addTrack(track: AlMediaTrack) {    if (tMap.containsKey(track.id)) {        return    }    tMap[track.id] = track    vMap[track.id] = TextView(context)    vMap[track.id]?.textSize = 14f    vMap[track.id]?.setTextColor(Color.WHITE)    vMap[track.id]?.text = when (track.type) {        AlMediaType.TYPE_VIDEO -> "Track ${track.id}"        AlMediaType.TYPE_AUDIO -> "Track ${track.id}"        else -> "Unknown Track"    }    vMap[track.id]?.setBackgroundColor(        when (track.type) {            AlMediaType.TYPE_VIDEO -> mVideoColor            AlMediaType.TYPE_AUDIO -> mAudioColor            else -> Color.RED        }    )    val padding = applyDimension(TypedValue.COMPLEX_UNIT_DIP, 8f).toInt()    vMap[track.id]?.setPadding(padding, padding, padding, padding)    addView(vMap[track.id], makeLayoutParams())    requestLayout()    //显示音频轨道波形图    updateAudioTrack(track)}

最后通过在onLayout方法中对AlTimelineView和AlTrackItemView进行布局,这里会根据轨道的时长占总时长的比例来设置AlTrackItemView自身的宽度。

override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {    var height = 0
var w = measuredWidth var h = mTimeView.measuredHeight mTimeView.measure(MeasureSpec.makeMeasureSpec(w, MeasureSpec.EXACTLY), h) mTimeView.layout(l, height, l + w, height + h) height += h
vMap.forEach { val track = tMap[it.key] val view = it.value
w = measuredWidth - paddingLeft - paddingRight h = view.measuredHeight var offset = 0 if (null != track && mTimeView.getDuration() > 0 && track.duration > 0) { offset = (track.seqIn * w / mTimeView.getDuration()).toInt() w = (track.duration * w / mTimeView.getDuration()).toInt() } view.layout(paddingLeft + l + offset, height, paddingLeft + l + w + offset, height + h) view.measure(MeasureSpec.makeMeasureSpec(w, MeasureSpec.EXACTLY), h)
height += h }}


AlTrackContainer
AlTrackContainer作为 AlTrackView的直接父级,承载着横向滚动的功能,我们可以继承 HorizontalScrollView实现。同时实现了缩放手势的监听,通过缩放手势计算缩放系数,层层传递到 AlTrackViewAlTimelineView进行缩放响应。缩放手势的监听很简单,只需要使用Android提供的ScaleGestureDetector即可。
private val mScaleDetector = ScaleGestureDetector(context, mScaleListener)private val mScaleListener = object : ScaleGestureDetector.SimpleOnScaleGestureListener() {    private var previousScaleFactor = 1f    override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {        previousScaleFactor = 1f        return super.onScaleBegin(detector)    }        override fun onScaleEnd(detector: ScaleGestureDetector?) {        previousScaleFactor = 1f        super.onScaleEnd(detector)    }        override fun onScale(detector: ScaleGestureDetector): Boolean {        val anchor = PointF(            detector.focusX * 2 / measuredWidth.toFloat() - 1f,            -(detector.focusY * 2 / measuredHeight.toFloat() - 1f)        )        scale = scale * detector.scaleFactor / previousScaleFactor        previousScaleFactor = detector.scaleFactor        //限制最大最小缩放系数        if (scale < 0.5f) {            scale = 0.5f        }        if (scale > 3) {            scale = 3f        }        //把缩放系数传给AlTrackView        getChildView().setScale(AlRational((scale * 10000).toInt(), 10000))        return super.onScale(detector)    }}override fun onTouchEvent(event: MotionEvent): Boolean {    mScaleDetector.onTouchEvent(event)    return super.onTouchEvent(event)}

同时AlTrackContainer还需要绘制中心的游标,用来标示当前的时间点,这里游标使用一个圆角矩形来表示。由于游标需要显示在所有元素的上方,如果在onDraw中绘制会被其它元素遮挡,所以需要在dispatchDraw中绘制。至此,高仿剪映多轨编辑View实现完成。

override fun dispatchDraw(canvas: Canvas?) {    super.dispatchDraw(canvas)    canvas?.drawRoundRect(        scrollX + (measuredWidth - cursorSize) / 2,        0f,        scrollX + (measuredWidth + cursorSize) / 2,        measuredHeight.toFloat(),        cursorSize / 2f,        cursorSize / 2f,        paint    )}


实际效果对比

高仿效果


剪映放大效果


总结
以上只是对剪映主要逻辑的实现,实际还缺失很多比较细微的功能,比如显示视频截图、删除移动轨道等,并且实际效果与剪映还有一些差异。希望通过本文能给读者学习Android自定义View带来一些帮助。最后附上源码:

AlTrackContainer

https://github.com/imalimin/hwvc/blob/develop/proj/hwvc_android/codec_native/src/main/java/com/lmy/hwvcnative/widget/AlTrackContainer.kt

AlTrackView

https://github.com/imalimin/hwvc/blob/develop/proj/hwvc_android/codec_native/src/main/java/com/lmy/hwvcnative/widget/AlTrackView.kt

AlTimelineView

https://github.com/imalimin/hwvc/blob/develop/proj/hwvc_android/codec_native/src/main/java/com/lmy/hwvcnative/widget/AlTimelineView.kt

Special
如果只是实现一个UI的交互功能,有点太缺乏挑战了。实际上本文不仅实现了用于编辑的交互UI,而且还实现了音视频多轨预览剪辑的逻辑。

  1. 支持同时添加多个音视频轨道进行播放预览!

  2. 支持剪映没有的多视频轨道图层移动和缩放,可以任意摆放各个视频轨道的位置!

  3. 支持常规的音视频Seek、暂停与播放等。

以上源码都开源在 hwvc 项目,感兴趣的读者可以 点击查看原文 自取。



技术交流,欢迎加我微信:ezglumes ,拉你入技术交流群。

推荐阅读:

音视频面试基础题

OpenGL ES 学习资源分享

一文读懂 YUV 的采样与格式

OpenGL 之 GPUImage 源码分析

推荐几个堪称教科书级别的 Android 音视频入门项目

觉得不错,点个在看呗~


本文分享自微信公众号 - 音视频开发进阶(glumes_blog)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

展开阅读全文
打赏
0
0 收藏
分享
加载中
更多评论
打赏
0 评论
0 收藏
0
分享
返回顶部
顶部