前言
使用 canvas 来实现简单的批注功能。
最近,公司需要做(抄)一个和剪映相似的页面,点我直达。
在剪映中,有一个批注的功能,能够对视频画面进行标注,效果如下:
![]()
在经过我对 dom 的分析之后,我发现这个功能是经过 canvas 实现。
具体就是在视频容器的区域内套一个 canvas 元素,然后在上面绘制。
在这篇文章中,我们主要是分析如何画出指向和方框这两种批注。
正文
dom 结构
首先,我们先创建一个 canvas 节点,这里我们用 vue 项目来实现。
1 2 3 4 5 6 7 8 9
| <script setup lang="ts"> import { ref } from "vue";
const canvasRef = ref<HTMLCanvasElement | null>(null); </script>
<template> <canvas ref="canvasRef" width="1280" height="720"></canvas> </template>
|
这里我们固定了宽高,当然实际上在 resize 事件(或者 ResizeObserver 观察器)时,我们可能需要重新设置 canvas 的宽高,这里我们简单处理。
接着我们要处理事件,这里我们主要需要三个事件,mousedown , mousemove 和 mouseup ,这里我们用了 vueuse/core ,主要是使用 useEventListener 这个组合式 api 。
这样我们可以专注于逻辑,而不用去在意事件的绑定与解绑。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| <script setup lang="ts"> import { ref } from "vue"; import { useEventListener } from "@vueuse/core";
const canvasRef = ref<HTMLCanvasElement | null>(null); let mousedown = false;
useEventListener(canvasRef, "mousedown", () => { mousedown = true; }); useEventListener("mousemove", () => { if (!mousedown) { return; } }); useEventListener("mouseup", () => { mousedown = false; }); </script>
|
接下来我们要考虑下存储的数据结构,在剪映中,数据结构如下:
1 2 3 4 5 6 7 8 9 10 11 12
| interface AnnotationItem { type: "rect" | "arrow"; from: { x: number; y: number; }; to: { x: number; y: number; }; }
|
当然,这里我们只保留一些核心的字段,在剪映中还有一些额外的字段,比如批注的颜色 color 字段,这里我们从简,统一使用 #ff0000 红色。
然后我们需要有两个变量来保存批注对象,其中一个为 annotationList ,保存已经不再变化的批注对象,另一个为 currentAnnotationItem , 保存当前正在创建的批注对象
这里我们需要一些坐标相关的计算,需要使用 vueuse/core 的 useElementBounding 来获取 canvas 的盒子信息
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68
| <script setup lang="ts"> import { ref } from "vue"; import { useEventListener } from "@vueuse/core";
interface AnnotationItem { type: "rect" | "arrow"; from: { x: number; y: number; }; to: { x: number; y: number; }; }
const canvasRef = ref<HTMLCanvasElement | null>(null); let mousedown = false; const annotationList = ref<AnnotationItem[]>([]); const currentAnnotationItem = ref<AnnotationItem | null>(null);
const { left: canvasLeft, top: canvasTop, width: canvasWidth, height: canvasHeight, } = useElementBounding(canvasRef);
useEventListener(canvasRef, "mousedown", ({ clientX, clientY }: MouseEvent) => { mousedown = true; currentAnnotationItem.value = { type: "rect", from: { x: clientX - canvasLeft.value, y: clientY - canvasTop.value, }, to: { x: 0, y: 0, }, }; }); useEventListener("mousemove", ({ clientX, clientY }: MouseEvent) => { if (!mousedown) { return; } Object.assign(currentAnnotationItem.value!.to, { x: clientX - canvasLeft.value, y: clientY - canvasTop.value, }); }); useEventListener("mouseup", ({ clientX, clientY }: MouseEvent) => { if (mousedown) { mousedown = false; Object.assign(currentAnnotationItem.value!.to, { x: clientX - canvasLeft.value, y: clientY - canvasTop.value, }); annotationList.value.push(Object.assign({}, currentAnnotationItem.value)); currentAnnotationItem.value = null; } }); </script>
|
现在我们已经得到了一个批注对象,接下来我们就需要将这个对象画到画布上面。
上面的代码中,在 mousedown 中使用了 type = "rect" 来初始化批注对象。
所以我们先来讲讲怎么画矩形标注。
矩形标注
在 Canvas 的 2D 上下文中,已经有一个现成的绘制矩形的 API 了,即 strokeRect(x, y, w, h) 。
![]()
这四个参数分别是,起始点的横坐标,起始点的纵坐标,矩形的宽,矩形的高。
这里我们可以看到剪映用的也是这个 API 。
![]()
现在我们的代码中保存了 currentAnnotationItem 这个对象,这个对象里面有起始点坐标和结束点坐标。
所以 API 需要的四个参数我们都能通过计算得到,我们写一个 drawRect 函数来绘制 currentAnnotationItem 所表示的矩形。
1 2 3 4 5 6 7 8 9 10 11
| const drawRect = () => { const ctx = canvasRef.value!.getContext("2d")!; const { from, to } = currentAnnotationItem.value!; const width = Math.abs(from.x - to.x); const height = Math.abs(from.y - to.y); ctx.beginPath(); ctx.strokeStyle = "#ff0000"; ctx.lineWidth = 2; ctx.strokeRect(from.x, from.y, width, height); ctx.closePath(); };
|
然后我们在 mousemove 事件中加上对这个函数的调用
1 2 3 4 5 6 7 8 9 10 11 12
| useEventListener("mousemove", ({ clientX, clientY }: MouseEvent) => { if (!mousedown) { return; } Object.assign(currentAnnotationItem.value!.to, { x: clientX - canvasLeft.value, y: clientY - canvasTop.value, }); drawRect(); });
|
效果如下:
![]()
可以发现现在的绘制存在两个问题:
- 问题 1 :每次绘制都会保留上次绘制的结果,导致显示错误。
- 问题 2 :由于我们固定起始点
from 为 strokeRect 的头两个参数,导致当结束点 to 在 from 的左上角时会出现绘制错误。
针对问题 1 ,我们需要在每次 mousemove 回调中的绘制前清除画布,我们实现一个 clearCanvas 函数,来清除画布上的当前内容。
清除画布的方法可以是调用 2D 上下文的 clearRect ,或者重新给 canvas 元素的宽高赋值,这里由于我们需要清空整个画布,所以用哪个都没差,这里我们使用后面的方法。
1 2 3 4 5
| const clearCanvas = () => { const el = canvasRef.value!; el.width = el.width; el.height = el.height; };
|
然后我们在 mousemove 中的 drawRect 之前调用一次 clearCanvas 。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| useEventListener("mousemove", ({ clientX, clientY }: MouseEvent) => { if (!mousedown) { return; } Object.assign(currentAnnotationItem.value!.to, { x: clientX - canvasLeft.value, y: clientY - canvasTop.value, }); clearCanvas(); drawRect(); });
|
效果如下:
![]()
可以看到现在不会出现重叠的情况了。
接下来我们开始解决问题 2 ,从上面的图可能看不出来问题 2 的症状,下面这个图就比较清晰了。
![]()
在我们从右下往左上拖动的时候,矩形绘制的区域明显错误了。
这个问题的根本原因在于我们固定了 currentAnnotationItem.from 作为矩形的起始点。
绘制的时候我们总共会出现四个绘制方向,分别是:
- 最常见的就是左上到右下
- 左下到右上
- 右上到左下
- 右下到左上
接着我们一个个分析。
1. 左上到右下
![]()
很明显此时绘制矩形的顶点就是 from 的坐标,这个很容易看出来。
2. 左下到右上
![]()
此时矩形的顶点是 (from.x, to.y) 。
3.右上到左下
![]()
此时矩形的顶点是 (to.x, from.y) 。
4.右下到左上
![]()
此时矩形的顶点是 to 的坐标。
经过分析之后,我们可以发现我们应该分别取 from 和 to 两者横纵坐标的较小值,这样绘制出来的矩形才是正确的
所以我们改动下 drawRect 代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| const drawRect = () => { const ctx = canvasRef.value!.getContext("2d")!; const { from, to } = currentAnnotationItem.value!; const width = Math.abs(from.x - to.x); const height = Math.abs(from.y - to.y); ctx.beginPath(); ctx.strokeStyle = "#ff0000"; ctx.lineWidth = 2; ctx.strokeRect( Math.min(from.x, to.x), Math.min(from.y, to.y), width, height ); ctx.closePath(); };
|
经过修改之后,效果如下:
![]()
现在矩形绘制基本上正确了
箭头标注
这应该是本文最难的一个点了,在刚开始我也是不会的,不过我也是看了剪映里面的绘制代码,调试了很久才勉强懂得了过程。
这里我们先放一下剪映的代码。
![]()
看起来还是相当复杂的,涉及了三角函数。
当然,这其中的基础是 2d 上下文的 moveTo , lineTo , fill API,分别是:
moveTo(x, y) 移动画笔到点 (x, y) 。
lineTo(x, y) 从起始点到 (x, y) 连接一条路径。
fill() 填充绘制路径围成的区域。
在剪映的代码中,最后就是调用这 3 个 API 来绘制图形
1 2 3 4 5 6 7 8 9 10 11 12 13
| t.prototype.draw = function() { e.beginPath(); e.fillStyle = c; e.moveTo(l.x, l.y); e.lineTo(_.x, _.y); e.lineTo(b.x, b.y); e.lineTo(f.x, f.y); e.lineTo(w.x, w.y); e.lineTo(E.X, E.y); e.fill(); e.closePath(); }
|
这里的每个点就是箭头标注的点,总共 6 个点,即 7 条边。
![]()
那么我们现在就是要求出这 6 个点的坐标,其他就水到渠成了。
其中两个点其实我们已经得到了,分别是 from 和 to ,对应的代码为 e.moveTo(l.x, l.y) 和 e.lineTo(f.x, f.y) 。
我们可以把剪映的代码稍稍转换一下,如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| t.prototype.draw = function() { d = Math.sqrt(Math.pow(to.x - from.x, 2) + Math.pow(to.y - from.y, 2)); h = Math.min(.2 * d, 35); p = .7 * h; y = Math.atan(Math.abs(to.y - from.y) / Math.abs(to.x - from.x)); v = Math.PI / 4; m = to.x > from.x ? 1 : -1; g = to.y > from.y ? 1 : -1; b = { x: to.x - h * Math.cos(v - y) * m, y: to.y + h * Math.sin(v - y) * g }; w = { x: to.x - h * Math.cos(v + y) * m, y: to.y - h * Math.sin(v + y) * g }; _ = { x: to.x - p * Math.cos(v - y - v / 2) * m, y: to.y + p * Math.sin(v - y - v / 2) * g }; E = { x: to.x - p * Math.cos(v + y - v / 2) * m, y: to.y - p * Math.sin(v + y - v / 2) * g }; }
|
首先变量 d 很容易看出来是计算点 from 和点 to 围成矩形的对角线的长度,这个公式是我们很熟悉的勾股定理 z2 = x2 + y2 。
变量 h 和 p 分别是根据变量 d 计算的一个的长度,往下看,可以发现分别对应去计算了两个点的坐标,其中变量 h 对应变量 b 和变量 w ,变量 p 对应变量 _ 和变量 E 。
y 则是矩形的对角线和横向形成的对角线,即下图的角 θ 。
![]()
Math.atan() 就是 Math.tan() 的“相反面”,它有一个专业的术语,叫反正切。
对于 Math.tan() ,传入角度,得到对边与领边的比值,而 Math.atan() 则是传入对边与领边的比值,得到角度。
v 则是 Math.PI 的 1/4 ,Math.PI 表示 180° , 1/4 也就是 45° 。
m 和 g 则是用来补偿正负判断的。
接下来我们假设从左下移动到右上,且夹角为 30° ,此时正负补偿都是 1 ,我们可以忽略这两个变量。
我们以下半部分的箭头来分析,此时如下:
![]()
接下来我们分析下下图中标的点,这里我们标为点 t 。
![]()
结合代码可以发现,由于四个待求的点 b, w, _, E 都是通过目标点 to 来进行转化的,而此时点 t 的横纵坐标应该都要小于 to ,即 to 的横纵坐标都要减去某个值来得到点 t 。
那么此时可以排除 b 和 _ ,剩下 w 和 E 。
在箭头的一边包含两个点,除了点 t ,还有一个 点 l ,如下图:
![]()
那么 w 和 E 应该就对应了这两个点。
此时我们假设 w 对应点 t ,然后我们分析是否符合。
此时 w 的计算如下(正负补偿已忽略):
1 2 3 4
| w = { x: to.x - h * Math.cos(v + y), y: to.y - h * Math.sin(v + y) };
|
可以发现通过 h 来以及正余弦来得出偏移量,那么我们可以确此时 h 就是斜边,而 h * Math.cos(v + y) 和 h * Math.sin(v + y) 就是直角边。
那么我们可以得出此时构造的直角三角形应该如下图所示:
![]()
此时角 α + 角 β 的值即为 y + v(45°) ,所以 h * Math.cos(v + y) 得出了偏移量 px ,h * Math.sin(v + y) 得出了偏移量 py 。
![]()
那么 t 的坐标也就的出来了。
同理我们分析 l 坐标,我们可以同样构造三角形,但是我们发现角度额外减少了 22.5°, 即 v(45°)/ 2 。
以及使用 p( =0.7 * h ) 来作为斜边,而不是 h ,此时我们把点 l 和 点 to 连接起来,此时两点的长度就是 p 。
我们直接上图,更容易理解:
![]()
此时 角 α + 角 β 的值即为 y + v / 2(22.5°) 。
其他的就和点 t 的计算过程一样了。
当然这里需要注意的是,v 的角度其实是可以自定义的,即如果你增大了 v 值,那么箭头顶部就会更加往外扩大,而如果你减小 v 值,那么箭头顶部就会往内收敛。
除此之外,h 和 p 也是可以自定义的,这两者决定了箭头的形状以及大小范围。
理解了计算过程后,我们就可以模仿(照抄)剪映写出 drawArrow 的代码了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| const drawArrow = () => { const ctx = canvasRef.value!.getContext("2d")!; const { from, to } = currentAnnotationItem.value!; const d = Math.sqrt(Math.pow(from.y - to.y, 2) + Math.pow(from.x - to.x, 2)); const h = Math.min(d * 0.2, 35); const v = Math.PI / 4; const y = Math.atan(Math.abs(to.y - from.y) / Math.abs(to.x - from.x)); const p = 0.7 * h; const m = to.x > from.x ? 1 : -1; const g = to.y > from.y ? 1 : -1; const p1 = { x: to.x - h * Math.cos(v - y) * m, y: to.y + h * Math.sin(v - y) * g, }; const p2 = { x: to.x - h * Math.cos(v + y) * m, y: to.y - h * Math.sin(v + y) * g, }; const p3 = { x: to.x - p * Math.cos(v - y - v / 2) * m, y: to.y + p * Math.sin(v - y - v / 2) * g, }; const p4 = { x: to.x - p * Math.cos(v + y - v / 2) * m, y: to.y - p * Math.sin(v + y - v / 2) * g, }; ctx.beginPath(); ctx.fillStyle = "#ff0000"; ctx.moveTo(from.x, from.y); ctx.lineTo(p3.x, p3.y); ctx.lineTo(p1.x, p1.y); ctx.lineTo(to.x, to.y); ctx.lineTo(p2.x, p2.y); ctx.lineTo(p4.x, p4.y); ctx.fill(); ctx.closePath(); }
|
然后我们可以看下效果图,如下:
![]()
相关的代码已经上传到我的仓库了,可以自行拉下来跑跑试试看。
当然,如果你想把代码放到业务中,可能还需要改一下,首先就是不能使用绝对值坐标来存,而应该使用百分比,因为每个人的屏幕分辨率可能不一样,这也是目前剪映的实现方法。
另外,也可以多自定义变量,比如颜色,方框的粗细等。
当然,标注的画布应该保持一个固定的长宽比,不然缩小屏幕可能会出现错位的情况。
后记
三角函数不用真的都快忘光了,虽然从 0 到 1 我不是很行,但是从 0.99 到 1 我还是可以的😂。
目前也已经把剪映这个页面的功能都搬到了我们公司的项目上。
不过我挺讨厌抄的…
嘛,不过工作嘛,完成工作而已,不要想太多。