当前位置: 首页 > news >正文

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

剪映是当下比较火的一款手机视频剪辑工具,由抖音官方推出,可用于手机短视频的剪辑制作,拥有强大的多轨编辑能力。其中视频剪辑页用于剪辑的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 音视频入门项目

觉得不错,点个在看呗~

相关文章:

  • 移动直播技术知多少:基础原理解析 腾讯云直播接入
  • 【Android 音视频开发打怪升级:FFmpeg音视频编解码】四、Android FFmpeg+OpenSL ES音频解码播放...
  • 一个简简单单的Plugin入门
  • 给大家分享一下阿里三面的面试真题
  • WebRTC Android 开发学习环境搭建~
  • Android 11 最终 Beta 版发布,正式版即将到来!
  • NDK中使用 MediaCodec 编解码视频
  • 【资源分享】免费学 清华大学 · 游戏程序设计公开课啦!!!
  • 谈一谈Android上的SurfaceTexture
  • 你还不知道 OpenGL ES 和 EGL 的关系?
  • 腾讯云视频云巅峰论剑——王者对决,等你来评!
  • 高大上的非线性编辑是怎么一回事?
  • C++ 万字长文第二篇---拿下字节面试
  • Android自定义View-SVG动画
  • 谈一谈Flutter外接纹理
  • [分享]iOS开发 - 实现UITableView Plain SectionView和table不停留一起滑动
  • 【108天】Java——《Head First Java》笔记(第1-4章)
  • 2017前端实习生面试总结
  • angular学习第一篇-----环境搭建
  • Java知识点总结(JavaIO-打印流)
  • js ES6 求数组的交集,并集,还有差集
  • JS基础篇--通过JS生成由字母与数字组合的随机字符串
  • log4j2输出到kafka
  • node 版本过低
  • node和express搭建代理服务器(源码)
  • Sass Day-01
  • Vue学习第二天
  • 京东美团研发面经
  • 什么软件可以提取视频中的音频制作成手机铃声
  • 为什么要用IPython/Jupyter?
  • 线上 python http server profile 实践
  • JavaScript 新语法详解:Class 的私有属性与私有方法 ...
  • 如何在 Intellij IDEA 更高效地将应用部署到容器服务 Kubernetes ...
  • ​​​【收录 Hello 算法】10.4 哈希优化策略
  • ​ubuntu下安装kvm虚拟机
  • #define MODIFY_REG(REG, CLEARMASK, SETMASK)
  • (02)Cartographer源码无死角解析-(03) 新数据运行与地图保存、加载地图启动仅定位模式
  • (20)目标检测算法之YOLOv5计算预选框、详解anchor计算
  • (22)C#传智:复习,多态虚方法抽象类接口,静态类,String与StringBuilder,集合泛型List与Dictionary,文件类,结构与类的区别
  • (PWM呼吸灯)合泰开发板HT66F2390-----点灯大师
  • (翻译)terry crowley: 写给程序员
  • (附源码)springboot 房产中介系统 毕业设计 312341
  • (力扣)循环队列的实现与详解(C语言)
  • (企业 / 公司项目)前端使用pingyin-pro将汉字转成拼音
  • (强烈推荐)移动端音视频从零到上手(上)
  • (亲测有效)解决windows11无法使用1500000波特率的问题
  • (三分钟了解debug)SLAM研究方向-Debug总结
  • (转载)在C#用WM_COPYDATA消息来实现两个进程之间传递数据
  • //TODO 注释的作用
  • @SuppressWarnings(unchecked)代码的作用
  • [AUTOSAR][诊断管理][ECU][$37] 请求退出传输。终止数据传输的(上传/下载)
  • [AutoSar]BSW_Memory_Stack_003 NVM与APP的显式和隐式同步
  • [BROADCASTING]tensor的扩散机制
  • [BUUCTF]-PWN:[极客大挑战 2019]Not Bad解析
  • [BZOJ1178][Apio2009]CONVENTION会议中心