Lottie动画:从制作到播放

Lottie 是由 Airbnb 开源,适用于 iOS、Android 和 React Native 的移动库。它解析 Adobe After Effects 动画,使用 Bodymovin 将这些动画导出为 json,并在移动设备上本地渲染它们!这里主要介绍如何制作 lottie 动画,通过脚本动态修改动画内容。

基本流程

基本流程

Bodymovin插件的安装与使用

插件配置

动画的制作与导出

新建合成

合成名称最好是英文或简拼,调整宽高、帧率(越大越流畅,最大建议60)、持续时间
新建合成

增加关键帧

导入图片或视频资源,右键资源添加效果,增加关键帧并调整交换或效果
动画效果
在交换或效果中添加关键帧

导出动画

打开菜单 窗口->拓展->Bodymovin,勾选要输出的动画,并设置输出文件目录,点击render,这时候我们得到JSON文件和资源文件夹
插件导出

JSON 字段含义

字段都是简称,我们只需搞清楚文本和资源如何修改,不必全部了解。
例如:
assets[0].p 表示图片资源
assets[1].nm 表示文本信息

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
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
const lottieJson = {
v: "5.6.10", // 使用bodymovie插件的版本
fr: 32, // 帧速率
ip: 0, // 合成开始时间
op: 64, // 合成持续时间
w: 750, // 合成宽度
h: 1334, // 合成高度
nm: "合成 1", // 合成名
ddd: 0, // 是否3d图层
assets: [
// 使用的资源
{
id: "image_0", // 使用的资源id
w: 750, // 当前图片资源的宽
h: 1334, // 当前图片资源的高
u: "images/", // 当前图片导出后在使用bodymovie导出后的文件夹
p: "img_0.jpg", // 当前图片资源路径
e: 0, // e=0 后将拼接u+p作为图片路径,e=1 不使用u,直接使用p的路径。
},
],
layers: [
// 图层
{
ddd: 0, // 是否是3d图层
ind: 1, // 当前图层所在的索引
ty: 2, // 2代表图片图层
nm: "img_0.jpg", // 源名称
cl: "jpg", // 后缀
refId: "image_0", // 使用assets中的id
sr: 1, // 图层 =>时间=>时间伸缩
ks: {
// 图层 => 变换
o: {
// 透明度
a: 1, // 是否是关键帧
k: [
// 如果是关键帧时是数组
{
// 每一个关键帧位置的配置信息
i: { x: [0.833], y: [0.833] }, // 当前贝塞尔曲线的入值,这个是在lottie中写死的值
o: { x: [0.167], y: [0.167] }, // 当前贝塞尔曲线的出值,这个是在lottie中写死的值
t: 0, // 当前关键帧开始时间
s: [60], // 开始的opacity
},
{
// 第二个关键帧的配置信息
i: { x: [0.833], y: [0.833] },
o: { x: [0.167], y: [0.167] },
t: 25,
s: [100],
}, // 第三个关键帧的配置信息
{
i: { x: [0.833], y: [0.833] },
o: { x: [0.167], y: [0.167] },
t: 30,
s: [100],
}, // 第四个关键帧的配置信息
{ t: 50, s: [50] },
],
ix: 11, // Property Index. Used for expressions。表达式标记。还没研究到这个怎么用
},
r: {
// 旋转
a: 0, // 是否是关键帧, 0代表不是关键帧
k: 0, // 不是关键帧时为number,旋转角度为0
ix: 10, // Property Index. Used for expressions。表达式标记
},
p: {
// 位置
a: 1, // 是关键帧
k: [
{
i: { x: 0.833, y: 0.833 }, // 当前贝塞尔曲线的入值,这个是在lottie中写死的值
o: { x: 0.167, y: 0.167 }, // 当前贝塞尔曲线的出值,这个是在lottie中写死的值
t: 0, // 开始时间
s: [-375, 675, 0], // 当前关键帧位置,横坐标-375,纵坐标675, 不是3d图层,z方向为0
to: [125, 0, 0], // In Spatial Tangent. Only for spatial properties. Array of numbers. 入值 还不知道空间切线在AE中是个什么鬼
ti: [-125, 0, 0], // Out Spatial Tangent. Only for spatial properties. Array of numbers. 出值 还不知道空间切线在AE中是个什么鬼
},
{
i: { x: 0.833, y: 0.833 },
o: { x: 0.167, y: 0.167 },
t: 25,
s: [375, 675, 0],
to: [0, 0, 0],
ti: [0, 0, 0],
},
{
i: { x: 0.833, y: 0.833 },
o: { x: 0.167, y: 0.167 },
t: 30,
s: [375, 675, 0],
to: [125.167, 0, 0],
ti: [-125.167, 0, 0],
},
{ t: 50, s: [1126, 675, 0] },
],
ix: 2, // Property Index. Used for expressions.
},
a: {
// 锚点
a: 0, // 不是关键帧
k: [375, 667, 0], // 锚点值
ix: 1, // Property Index. Used for expressions.
},
s: {
// 缩放比例
a: 0, // 不是关键帧
k: [100, 100, 100], // // 缩放比例值
ix: 6, // Property Index. Used for expressions.
},
},
ao: 0, // 是否自动跟踪
ip: 0, // 开始帧
op: 64, // 持续帧长
st: 0, // 开始时间
bm: 0, // 混合模式
},
{
"ddd": 0,
"ind": 1,
"ty": 5,
"nm": "中华宝藏",
"sr": 1,
"ks": {
"o": { "a": 0, "k": 100, "ix": 11 },
"r": { "a": 0, "k": 0, "ix": 10 },
"p": { "a": 0, "k": [136, 112, 0], "ix": 2, "l": 2 },
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
"s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 }
},
"ao": 0,
"t": {
"d": {
"k": [
{
"s": {
"s": 36,
"f": "MicrosoftYaHeiLight",
"t": "中华宝藏",
"ca": 0,
"j": 0,
"tr": 0,
"lh": 43.2000007629395,
"ls": 0,
"fc": [0.922, 0.922, 0.922]
},
"t": 0
}
]
},
"p": {},
"m": { "g": 1, "a": { "a": 0, "k": [0, 0], "ix": 2 } },
"a": []
},
"ip": 0,
"op": 900,
"st": 0,
"ct": 1,
"bm": 0
},
],
markers: [],
};

动画可控制

通过API控制动画开始和暂停,指定动画执行范围(playSegments)
详情见文档:
https://github.com/airbnb/lottie-web#lottie-has-several-global-methods-that-will-affect-all-animations

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 初始化
const animation = lottie.loadAnimation({
container: element,
loop: true,
autoplay: false,
path: "https://*.json",
});
animation.goToAndStop(1, true);

// 抽奖
btn.addEventListener("click", () => {
animation.play();
fetch("/trigger").then(() => {
animation.stop();
// 展示抽奖结果
});
});

动画可交互

由于 Lottie 的 JSON 内描述了动画的各种细节如关键帧、位移轨迹等等,因此如果在动画播放时,根据用户的交互去动态改变这些参数,就可以实现可交互的动画,比如下面这个著名的变色龙吃鼠标的 Lottie,眼睛会随着鼠标而移动,并且不时会吐出舌头「吃掉」鼠标
交互动画DEMO

其它示例:
https://lottiefiles.com/search?q=Frog&category=animations

用 async/await 优雅地控制 Lottie 播放

三段动画分别控制执行
025帧 掷骰子
26
35帧 骰子旋转
36~36

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 播放掷骰子的动画
async function playRoll(animation, isPending) {
await playAnimation(animation, [0, 25]);
while (isPending()) {
await playAnimation(animation, [26, 35]);
}
}

btn.onclick = async () => {
let number;
// 并发 ajax 与掷骰子动画
await Promise.all([
fetch("/*.json").then((data) => (number = data.number)),
playRoll(animation, () => !number),
]);

// 两者都完成后,播放筛子点数动画
const segementStart = 36 + (number - 1) * 4; // 根据点数换算起始帧
playAnimation(animation, [segementStart, segementStart + 4]);
};

动态替换文本

修改 lottie.json

1
2
3
4
5
6
7
8
9
10
11
12
13
fetch(
"https://*.json"
)
.then((resp) => resp.text())
.then((text) => {
// 简单演示替换
const newJSON = text.replace("${文本}", "拾亿");
lottie.loadAnimation({
animationData: JSON.parse(newJSON),
container: document.getElementById("app"),
loop: true,
});
});

修改 JS 对象

通过 getKeyPath 获取元素,comp1 指合成名称,textnode 指的是文本节点

1
2
3
4
5
anim.addEventListener("DOMLoaded", () => {
const api = lottie_api.createAnimationApi(anim);
const elements = api.getKeyPath("comp1,textnode"); // 查找对象
elements.getElements()[0].setText("拾亿");
});

修改 svg 元素

如果设计师在 AE 图层命名时尾部加入 #xxx ,那么生成的 svg 元素就会有一个 id 属性为 xxx
于是根据这个黑科技,我们可以这么写

1
2
3
4
anim.addEventListener("DOMLoaded", () => {
const element = document.getElementById("J_txt");
element.querySelector("tspan").innerHTML = "拾亿";
});

在导出 json 时抽取字体库中的字形(Glyphs)路径放入文件,canvas 模式下通过路径绘图来画出文本

所以上述方案中的 C 肯定无法支持 canvas 模式,而 A/B 要支持则依赖于导出 JSON 时,要替换的文本字形是否已经存在于 JSON 中,因此从实际出发,一般 canvas 模式下就比较难实现替换文本了

另外根据上面的原理,上面所有例子中,从 AE 导出 lottie 时都记得去掉「Glyphs」这个选项,并且指定具体的 font family,记得把这件事告诉你的设计师小伙伴

综合比较三种方式,我更喜欢修改 JS 对象,因为基于中间产物的修改方式扩展性是最强的,比如能够支持运行期,还可以修改大小、颜色、替换指定关键帧等等

动态替换图片

assets 中资源 p 可以是 dataURL、图片相对路径、远端地址

修改 lottie.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const resp = await fetch(
"https://*.json"
);
const json = await resp.json();

// 找到对应 json 节点直接替换 p 属性
const asset = json.assets.find((a) => a.id === "7");
asset.p =
"https://*.jpg";

lottie.loadAnimation({
container: mountNode,
animationData: json,
});

修改 JS 对象

相比修改文本,要修改图片略麻烦一些,主要是 lottie-web 本身并没有直接提供类似 updateDocumentData 这样的方法,不过只要知道实现原理,找到解法并不难

通过阅读 lottie-web 源码,可以发现 svg 和 canvas 模式下,图片的实现不一样(svg, canvas),所以我们的修改方案也需要判断先判断 renderer 模式

示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
anim.addEventListener('DOMLoaded', () => {
if (anim.renderer.rendererType === 'canvas') {
// canvas 模式下的图片替换
anim.renderer.elements[0].elements[8].img.src = 「'https://*.jpg';
} else {
// svg 模式下的图片替换,前两个参数为固定值
anim.renderer.elements[0].elements[8].innerElem.setAttributeNS(
'http://www.w3.org/1999/xlink',
'href',
'https://*.jpg'
);
}
});

修改 svg 元素

与文本同理,仅限于 svg 渲染模式,只需要设计师在图层命名时,尾部加入 #xxx 即可,这样生成的 svg 元素就会有一个 id 属性为 xxx,比如
那么通过 DOM API 找到元素修改属性即可

1
2
3
4
5
anim.addEventListener("DOMLoaded", () => {
const element = document.getElementById("xxx");
element.querySelector("image").href =
"https://*.jpg";
});

动态修改 lottie 中的图片,与动态修改文本大同小异,只是 JSON 结构的属性、JS 对象的 API 有所不同,另外图片不像文本,在 canvas 模式下可以正常使用,lottie 导出时也没有特殊的要求

动画转视频

  1. 使用 canvas.captureStream 将 canvas 转为 stream
  2. 使用 MediaRecorder 录制 stream 并转 blob
  3. 使用 ffmpeg.wasm 将 mp3 音频与视频 blob 混合并输出 mp4

源码如下:

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
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
import { Options, Deferred, CanvasElement, MediaRecorderEvent } from './type';

declare var MediaRecorder: any;

const defaultOptions: Partial<Options> = {
mimeType: 'video/webm;codecs=h264',
outVideoType: 'mp4',
transcodeOptions: '',
concatDemuxerOptions: '-c:v copy -af apad -map 0:v -map 1:a -shortest'
}

export class Canvas2Video {
private deferred: Deferred;
private recorder: any;
config: Options;
constructor(config: Options) {
this.config = Object.assign({}, defaultOptions, config);
}
/**
* start to record canvas stream
*/
startRecord(): void {
const deferred: Deferred = {};
deferred.promise = new Promise((resolve, reject) => {
deferred.resolve = resolve;
deferred.reject = reject;
})
this.deferred = deferred;
const stream = (<CanvasElement>this.config.canvas).captureStream();
const recorder = new MediaRecorder(stream, { mimeType: this.config.mimeType });
const data: any[] = [];
recorder.ondataavailable = function (event: MediaRecorderEvent) {
if (event.data && event.data.size) {
data.push(event.data);
}
};
recorder.onstop = () => {
this.deferred.resolve(new Blob(data, { type: this.config.mimeType }));
};
recorder.start();
this.recorder = recorder;
}
/**
* stop to record canvas stream
*/
stopRecord(): void {
this.recorder.stop();
}
/**
* merge audio and convert video type
* @param url
*/
private async convertVideo(blob: Blob): Promise<string> {
const { audio, outVideoType, mimeType, workerOptions, transcodeOptions, concatDemuxerOptions } = this.config;
const { createFFmpeg, fetchFile } = window.FFmpeg;
const ffmpeg = createFFmpeg(workerOptions || {});
await ffmpeg.load();
const type = mimeType.split(';')[0].split('/')[1];
const buffer = await blob.arrayBuffer();
await ffmpeg.FS('writeFile', `video.${type}`, new Uint8Array(buffer));

if (audio) {
const audioData = await fetchFile(audio);
const audioType = audio.split('.').pop();
await ffmpeg.FS('writeFile', `1.${audioType}`, audioData);
const items = concatDemuxerOptions.split(/\s+/).filter(item => item);
await ffmpeg.run(...['-i', `video.${type}`, '-i', `1.${audioType}`, ...items, `out.${outVideoType}`]);
} else {
if (type !== outVideoType) {
const items = transcodeOptions.split(/\s+/g).filter(item => item);
await ffmpeg.run(...['-i', `video.${type}`, ...items, `out.${outVideoType}`]);
}
}
const data = await ffmpeg.FS('readFile', `out.${outVideoType}`);
const b = new Blob([data.buffer], { type: `video/${outVideoType}` })
return URL.createObjectURL(b);
}
/**
* get canvas stream url, created by URL.createObjectURL & Blob
*/
async getStreamURL(): Promise<string> {
const blob = await this.deferred.promise;
const { mimeType, audio, outVideoType } = this.config;
const type = mimeType.split(';')[0].split('/')[1];
if (type === outVideoType && !audio) {
return URL.createObjectURL(blob);
}
if (!window.FFmpeg) {
const err = new Error('please load FFmpeg script file like https://unpkg.com/@ffmpeg/ffmpeg@0.7.0/dist/ffmpeg.min.js');
return Promise.reject(err);
}
return this.convertVideo(blob);
}
}

参考资料

Lottie-web 官方文档

Lottie 能做哪些事情?

lottie-web 系列之一:如何看懂 json 参数

剖析 lottie-web 动画实现原理

插件安装及 AE 动画实现

哔哩哔哩 AE 纯代码动画实现方法

canvas to video, convert to mp4, merge audio


Lottie动画:从制作到播放
http://example.com/20230918-Lottie动画:从制作到播放/
作者
csorz
发布于
2023年9月18日
许可协议