目录
前言
1.代码地址与demo效果图
2.项目技术架构分析
3. PixiJS
3.1 pixi常见概念介绍
3.2 创建应用
3.3 预加载资源
3.4 初始化场景
3.5 初始化精灵
4. AlloyTouch
5. TimelineMax
6. 初始化动画
7. 舞台的整体缩放与旋转
8.添加背景音乐
总结
后记
// 续更 2019-09-08
(1)运动属性的最小值应该怎么计算?
(2)为什么后面会剧烈抖动??
(3)动画的delay与duration如何计算???
网易四字魔咒:http://news.163.com/special/fdh5_tolerance/(手机扫描以下二维码查看)
偶然机会看到网易的四字魔咒,觉得so cool!一开始以为只是简单的CSS3动画,后来一研究,确实不简单。CSS3动画有一定的局限性,实现不了这么流畅的一镜到底的效果。那就入坑吧,来研究一波如何实现一镜到底。
先挂上最终实现的效果图跟仓库地址:https://gitee.com/wuchunling/pixiJS.git
项目主要用到了PixiJS(https://www.pixijs.com/),一个2D动画引擎,看官方文档稍微吃力,也有一个翻译的中文教程:https://github.com/Zainking/learningPixi,中英文配合看,基本pixiJS的API就能初步掌握。
先来总结一下项目的实现整体的实现步骤:
(1) 创建PIXI应用,预加载图片资源,资源加载完成后将PIXI应用插入真实DOM中,进行下一步
(2) 初始化场景:定义每个场景的宽高、位置等属性数据,通过new PIXI.Container()函数创建每个场景,将他们加入PIXI舞台中
(3) 初始化精灵:定义每个精灵的位置等属性数据,将其加到对应的场景中
(4) 进行到第三步,所有的精灵都绘制出来了,但是此时屏幕还不可以拖动,这里需要用到一个滑动的库,我用的是AlloyTouch,网易四字魔咒用的是ScrollerJS。通过AlloyTouch可以检测用户滑动的距离,在对应的滑动的change回调函数里可以改变舞台的位置app.stage.position.x来实现舞台的拖动效果。这样用户就可以通过滑动查看了。
(5) 进行到第四步,一切的效果都是静态的,我们的元素还没有动起来。要实现随着用户的滑动播放对应的动画效果,这里需要用到一个库TimelineMax。这是管理动画播放进度条的库。直接new 一个主时间轴timeline。
(6) 给精灵加上动画,这里用到一个库TweenMax,可以用来创建补间动画。我们只需定义精灵的起始状态,最终状态,它能轻松帮助我们进行状态的过渡。用TweenMax创建完动画,将动画加到时间轴timeline对应的位置。delay:0.1表示在动画长度的百分十处开始播放。
(7) 在第三步的时候,用户滑动的回调函数加上 timeline.seek(progress)就可以实现滑动到某个位置播放对应的动画。
这里,先解释一下每个库的作用
1) PixiJS: 绘图,其中包括舞台、场景、普通精灵、动画精灵、平铺精灵、定时器等。
2) TweenMax:制作过渡动画
3) TimelineMax:管理整个舞台的动画播放进度
4) AlloyTouch:实现滑动效果,监听用户滑动
PixiJS+TweenMax+TimelineMax+AlloyTouch就能实现一镜到底的套路,每个库发挥它自己应有的作用,相互配合。
①舞台/Stage:舞台只有一个,app.stage就是我们的舞台。所有要被显示的东西最终都是放到舞台里去的。
②精灵/Sprite:精灵就是舞台里的每一个元素,例如一只鸟、一张背景图、一段文本等。可以通过操作精灵的某些属性达到我们想要的动画效果。同时,精灵也分很多种类,普通精灵Sprite、平铺精灵TilingSprite,动画精灵AnimatedSprite。平铺精灵一般用于小图片需要平铺成背景,动画精灵一般用于制作帧动画。
③容器/Container:容器,可以用来放置精灵,舞台的本质也是一个容器,所以容器有的属性舞台也有,舞台继承自容器。当我们舞台里的精灵元素太多太杂,可以通过设置多个容器,把精灵放到对应的容器里,再把容器放到舞台里,这样就方便管理了。这也就是一镜到底会用到的多场景切换。每个场景其实就是一个容器,每个场景有自己的精灵。舞台只有一个(舞台继承自容器),但容器可以有多个。
④加载器/Loader:因为要用到大量的图片,图片的加载比较费时间,所以需要进行一次性的预加载,这也就是在网易四字魔咒开头看到的进度条的由来,利用Loader可以预加载图片,同时跟踪进度。
项目的一开始,需要创建一个pixi应用,直接通过一个语句 new PIXI.Application(),这样我们的PIXI应用就创建完成了。
let app = new PIXI.Application({
width:1334,
height:750
});
一般把舞台的宽高设置成跟设备屏幕宽高等同。注意舞台的背景色是0x写法。例如十六进制色值#000555;在这里写成 0x000555
// 创建PIXI应用
const w = document.body.clientWidth,
h = document.body.clientHeight;
const app = new PIXI.Application({
width:w,
height:h,
backgroundColor:0xd7a664
});
loader.add可以链式调用,将全部的资源进行预加载。
progress回调函数帮助我们监听加载的进度;complete回调函数表示资源全部加载完成,这时候就可以把PIXI应用插入到真实DOM中了。
定义好加载器的所有东西,loader.load()就开始加载资源了。加载好的资源可以通过loader.resources调用
(代码中涉及到舞台的整体缩放跟旋转的问题留到最后讲。)
// 创建资源加载器loader ,进行资源预加载
const loader = new PIXI.loaders.Loader();
// 链式调用添加图片资源
loader.add('bg1', './imgs/bg1.png')
.add('bg_desk', './imgs/bg_desk.png')
.add('bg_person','./imgs/bg1_person.png')
// 监听加载进度,显示加载进度
loader.on("progress", function(target, resource) { //加载进度
document.getElementById('percent').innerText = parseInt(target.progress)+"%";
});
// 监听加载完毕
loader.once('complete', function(target, resource) { //加载完成
document.getElementById('loading').style.display = 'none'; // 隐藏进度条
document.body.appendChild(app.view); // 将pixi应用插入真实DOM中
initScenes(); // 初始化场景
initSprites(); // 初始化精灵
initAnimation(); // 初始化动画
app.stage.scale.set(scale,scale); // 根据设备屏幕实际宽高缩放舞台
if (w
根据设计图,得出每个场景的长宽与位置。新建场景容器,等建完场景,才可以进行下一步的操作,将精灵放进场景里。
建场景的操作比较简单,定义每个场景的数据,新建Container对象并加入舞台中。这里的实现主要有三个东西:
①场景数据 scenesOptions:定义每个场景的数据
②对象集合 scenes:PIXI.Container对象,在初始化精灵的时候需要用到,所以需要将每个场景对象进行存储
③循环函数 initScenes:初始化场景并加入舞台里
const scenesOptions = [ // 场景数据:定义每个场景的宽高,x/y距离
{
name:"scene1",
x:0,y:0,
width:2933,height:750
},
{
name:"scene2",
x:2933,y:0,
width:1617,height:750
},
....
];
const scenes = {}; // 场景集合 - pixi对象
function initScenes(){ // 初始化场景
for (let i = scenesOptions.length-1; i >= 0 ; i--) {
scenes[scenesOptions[i].name] = new PIXI.Container({
width:scenesOptions[i].width,
height:scenesOptions[i].height
});
scenes[scenesOptions[i].name].x = scenesOptions[i].x;
app.stage.addChild(scenes[scenesOptions[i].name]);
}
}
这一步的操作与上一步操作一致:数据集,对象集,循环函数。
只是在这里我把精灵的初始化拆分成两个函数,一个是循环精灵数组,一个是循环每一个精灵的属性。再加了一个特殊属性的函数。根据需要,可以对某些精灵进行特殊的操作,都统一放在initSpecialProp函数里。
const spritesOptions = [ // 精灵数据:定义每个精灵的坐标
{ // 第一个场景的精灵
bg1:{
position:{x:0,y:0}
},
bg_person:{
position:{x:0,y:19},
anchor:{x:0.5,y:0.5}
},
....
},
{ // 第二个场景的精灵
bg_desk:{
position:{x:2213,y:38}
},
....
}
];
const sprites = {}; // 精灵集合 - pixi对象
function initSprites(){ // new出所有精灵对象,并交给函数initSprite分别赋值
for (let i = 0; i < spritesOptions.length; i++) {
let obj = spritesOptions[i];
for (let key in obj) {
if (obj.hasOwnProperty(key)) {
sprites[key] = PIXI.Sprite.fromImage(key);
initSprite(sprites[key],obj[key],i+1);
}
}
}
initSpecialProp();
}
function initSprite(sprite,prop,i){ // 初始化单个精灵的属性并加入对应的场景中
for (let key in prop) {
if (prop.hasOwnProperty(key)) {
sprite[key] = prop[key];
}
}
scenes['scene'+i].addChild(sprite);
}
function initSpecialProp(){ // 若有特殊精灵要处理特殊属性,可在此函数内处理
// sprites.mother_left.pivot.set(0,51);
// sprites.mother_right.pivot.set(95,50)
}
进行到上面那一步,舞台场景精灵一应俱全,可以看到绘制出来的效果,但是此时页面还不可以拖动,还需要一个滑动的库来配合。
new AlloyTouch({ ... }):
①touch定义触摸的DOM对象,在这里我们就直接是body了;
②vertical定义触摸的方向(横向滑动,还是竖向滑动。这里涉及设备是横屏还是竖屏,所以通过值vertical传进来。横屏则为false,竖屏则为true);
③可滚动的最大距离max跟最小距离min。因为我们都是往左滑,往上滑,所以为负距离,所以最大值max为0。最小值为舞台的整体宽度再减去一整屏的宽度,然后再取负值;
④关键点在于change函数,他可以返回实时滚动的距离。通过计算可以得到当前滚动的距离占全部距离的百分比,这个百分比就是我们当前的进度。拿到这个百分比。(默认总进度为1)就可以通过timeline.seek函数就可以随时改变播放的进度。这就是为什么往回滑动动画会往回撤的原因。想一下我们平时看视频,我们滚动进度条的行为就是用户滑动页面的行为,所以这就是实现的关键点。
let alloyTouch;
function initTouch(vertical, val) {
let scrollDis = app.stage.width-max;
alloyTouch = new AlloyTouch({
touch:"body", //反馈触摸的dom
vertical: vertical, //不必需,默认是true代表监听竖直方向touch
min: -app.stage.width + max, //不必需,运动属性的最小值
maxSpeed: 1,
max: 0, //不必需,滚动属性的最大值
bindSelf: false,
initialValue: 0,
change:function(value){
app.stage.position[val] = value;
let progress = -value/scrollDis;
progress = progress < 0 ? 0 : progress;
progress = progress > 1 ? 1 : progress;
timeline.seek(progress);
}
})
}
TimelineMax是GSAP动画库中的动画组织、排序、管理工具,可创建时间轴(timeline)作为动画或其他时间轴的容器,这使得整个动画控制和精确管理时间变得简单。
const timeline = new TimelineMax({ // 整个舞台的时间轴
paused: true
});
最后,将4跟5步骤合在一起如下
const w = document.body.clientWidth,
h = document.body.clientHeight;
const min = (wh)?w:h;
const timeline = new TimelineMax({ // 整个舞台的时间轴
paused: true
});
let alloyTouch;
function initTouch(vertical, val) {
let scrollDis = app.stage.width - max;
alloyTouch = new AlloyTouch({
touch:"body", //反馈触摸的dom
vertical: vertical, //不必需,默认是true代表监听竖直方向touch
min: -app.stage.width + max, //不必需,运动属性的最小值
maxSpeed: 1,
max: 0, //不必需,滚动属性的最大值
bindSelf: false,
initialValue: 0,
change:function(value){
app.stage.position[val] = value;
let progress = -value/scrollDis;
progress = progress < 0 ? 0 : progress;
progress = progress > 1 ? 1 : progress;
timeline.seek(progress);
}
})
}
时间轴准备好了。就可以定义动画,将动画加到时间轴上了,表示在时间轴的哪个位置开始播放动画。动画这里用到的是TweenMax库,当然,也可以用别的库,只要能实现补间动画即可。TweenMax常用的三个函数如下:
①TweenMax.to(target,duration,statusObj) ——目标target从当前状态到statusObj状态过渡
②TweenMax.from(target,duration,statusObj) ——目标target从statusObj状态到当前状态过渡
③TweenMax.fromTo(target,duration,statusObjFrom,statusObjTo)——目标target从statusObjFrom状态到statusObjTo状态过渡
duration表示过渡时长。如果duration=0.1则表示过渡时长占滚动总长的10%,即占时间轴的10%。
delay跟duration的计算规则:
1) delay = 开始播放动画时的滚动距离 / 可滚动总长度
delay是动画开始的时间
2) duration = (结束播放动画时的滚动距离 - 开始播放动画时的滚动距离) / 可滚动总长度
duration是动画持续的时间
const animationsOptions = { // 精灵动画集合
windows:[{
prop:'scale', // 这里有个prop,有些人没有注意到这是什么
delay:0.05,
duration:0.3,
to:{x:3,y:3,ease:Power0.easeNone} // 在这里注意一下 ease:Power0.easeNone 是缓动函数
},{
delay:0.1,
duration:0.1,
to:{alpha:0}
}],
talk_1:[{
delay:0.15,
duration:0.1,
from:{width:0,height:0,ease:Power0.easeNone}
}]
}
function initAnimation(){
// delay=0.1 表示滚动到10%开始播放动画
// duration=0.1 表示运动时间占滚动的百分比
for (let key in animationsOptions) {
if (animationsOptions.hasOwnProperty(key)) {
let obj = animationsOptions[key];
for (let i = 0; i < obj.length; i++) {
let act;
let target;
if (obj[i].prop) {
target = sprites[key][obj[i].prop];
} else {
target = sprites[key];
}
if (obj[i].from & obj[i].to) {
act = TweenMax.fromTo(target,obj[i].duration,obj[i].from,obj[i].to);
} else if (obj[i].from) {
act = TweenMax.from(target,obj[i].duration,obj[i].from);
} else if (obj[i].to) {
act = TweenMax.to(target,obj[i].duration,obj[i].to);
}
let tm = new TimelineMax({delay:obj[i].delay});
tm.add(act,0);
tm.play();
timeline.add(tm,0);
}
}
}
// 特殊动画特殊处理
let act = TweenMax.to(scenes.scene1,0.3,{x:2400});
let tm = new TimelineMax({delay:0.25});
tm.add(act,0);
timeline.add(tm,0);
}
在这里注意一下 ease:Power0.easeNone 是缓动函数 戳链接:https://www.tweenmax.com.cn/api/tweenmax/ease
我一开始没有写缓动函数,动画出现严重的卡帧效果,真丑陋!我还以为是设备问题,是pixi问题。最后发现了这个缓动函数。给动画加上线性缓动,完美解决了卡帧的丑陋效果。
tip!!!!!(有热心网友问到我上面的prop是干什么用的)
注意一下,TweenMax.from(target,duration,statusObj)等方法只识别target的属性。举个例子,精灵sprite的width从0变到100
TweenMax.to(sprite, 0.1, { width: 100}) // 即 sprite.width变为100
那么问题来了,要改变属性的属性呢?例如sprite.scale.x变为2,则target应该变为sprite.scale
TweenMax.to(sprite.scale, 0.1, { x: 2}) // 即 sprite.scale.x变为100
为了兼容这两种情况,就需要加入prop。我可真是个小机灵鬼。
拿上面的animationsOptions.window举个例子。可以观察到animationsOptions.window其实是一个数组,数组里面的一个个对象代表一个个动画,所以组成精灵window的动画集合。我们需要对精灵window做很多动画的话,例如用时位移+缩放+变透明等等,如果是delay跟duration相同并且是window的一级属性(即window.prop),可以放在一个对象里,如果delay或者duration不同,就需要设置成不同的对象。为了兼容二级属性,即window.prop.prop,我特意加了一个参数prop,达到兼容二级属性的效果。
在下面初始化动画的函数initAnimation里,才有这一步,先判断是否是二级属性
if (obj[i].prop) {
target = sprites[key][obj[i].prop];
} else {
target = sprites[key];
}
设计图是 1334*750. 不修改图片大小,直接将图片精灵加入舞台里,最后对这个舞台进行整体缩放,这样就省心省力很多。需要对当前设备的横屏还是竖屏进行检测。
实现步骤:
1)获取整个屏幕的宽高;
const w = document.body.clientWidth, // 或者 window.innerWidth
h = document.body.clientHeight; // 或者 window.innerHeight
2)判断宽高大小,取最小的边 作为舞台的高,与 750做比较,得出缩放比例;
const min = (wh)?w:h;
let scale = min/750; // 根据设计稿尺寸进行缩放比例调整
console.log(w,h,min,"放大系数:",scale);
app.stage.scale.set(scale,scale); // 根据屏幕实际宽高放大舞台
3)对舞台进行旋转;
if (w
4)判断设备状态的切换
//判断手机横竖屏状态:
window.addEventListener("onorientationchange" in window ? "orientationchange" : "resize", function() {
if (window.orientation === 180 || window.orientation === 0) {
alert('竖屏状态!');
location.reload();
}
if (window.orientation === 90 || window.orientation === -90 ){
alert('横屏状态!');
location.reload();
}
}, false);
在不同的进度处播放不同的背景音乐,只需在之前的initTouch的change回调函数里调用播放背景音乐的函数 playAudio(progress),重点处理一下playAudio(progress)函数的编写即可。
function initTouch(vertical,val){
let scrollDis = app.stage.width-max-max;
alloyTouch = new AlloyTouch({
touch:"body",//反馈触摸的dom
vertical: vertical,//不必需,默认是true代表监听竖直方向touch
min:-app.stage.width+max+max,
maxSpeed: 1,
max: 0, //不必需,滚动属性的最大值
bindSelf: false,
initialValue: 0,
change:function(value){
app.stage.position[val] = value - max;
let progress = -value/scrollDis;
progress = progress < 0 ? 0 : progress;
progress = progress > 1 ? 1 : progress;
timeline.seek(progress);
console.log(value,progress);
// 播放背景音乐
playAudio(progress);
}
})
}
播放背景音乐的处理相对比较简单粗暴,将就看吧。
function playAudio(progress){
if (progress>=0.08 && progress<=0.081) {
playBgmAfterLoading('wechat');
setTimeout(function(){
tickerPhone.stop();
playBgmAfterLoading('talk_5','talk_6',2000);
},2000)
}
if (progress>=0.227 && progress<=0.23) {
playBgmAfterLoading('talk_7');
}
if (progress>=0.357&& progress<=0.36) {
playBgmAfterLoading('talk_8','talk_9',3000);
}
if (progress>=0.444 && progress<=0.45) {
playBgmAfterLoading('talk_10');
}
if (progress>=0.433 && progress<=0.44) {
actTrumpet.play();
playBgmAfterLoading('train','train1',4000);
setTimeout(function(){
actTrumpet.pause();
},8000)
}
}
function playBgmAfterLoading(e,next,wait) {
playBgm(e);
if (next) {
setTimeout(function(){
playBgm(next);
},wait);
}
}
function playBgm(e){
let audio = document.getElementById(e);
if (typeof WeixinJSBridge == "object" && typeof WeixinJSBridge.invoke == "function") {
WeixinJSBridge.invoke('getNetworkType', {}, function (res) {
// 在这里拿到 e.err_msg, 这里面就包含了所有的网络类型
// alert(res.err_msg);
audio.play();
});
} else {
audio.play();
}
}
完结撒花,待优化。下次遇到新奇玩意就来续更。
还有一个更加完整的demo作品(代码仓库地址):https://gitee.com/wuchunling/life-amp-activity
(示例:扫码查看完整demo)
或者直戳:http://pixi.chunling.online/
最近陆陆续续收到网友的疑问,对于一些值的计算不甚清楚,我也觉得自己这篇博文写得不够好,没有把一些细节展开说。有必要整理一下这些疑问。
很高兴收到一封邮件,有人对pixiJS的demo提出了疑问,我也回头看了自己的代码,代码确实有bug。因为这只是我的练手demo,很多细节没有去深究,我还是建议大家看https://gitee.com/wuchunling/life-amp-activity这个仓库的代码。代码差不多,但是这个更完整,更完善,也修复了一些小bug,源代码看public文件夹即可,不需要看dist文件夹哦,要看dist文件夹,建议clone代码后再gulp dist一下。
function initTouch(vertical,val){
let scrollDis = app.stage.width-max-max;
alloyTouch = new AlloyTouch({
touch:"body",//反馈触摸的dom
vertical: vertical,//不必需,默认是true代表监听竖直方向touch
min: -app.stage.width + max, // 这个值应该怎么计算呢??????????????????????
maxSpeed: 1,
max: 0,
bindSelf: false,
initialValue: 0,
change:function(value){
app.stage.position[val] = value - max;
let progress = -value/scrollDis;
progress = progress < 0 ? 0 : progress;
progress = progress > 1 ? 1 : progress;
timeline.seek(progress);
console.log(value,progress);
// 播放背景音乐
playAudio(progress);
}
})
}
看看这段代码,new AlloyTouch({})里的min-max其实就是可滚动的最大值跟最小值。
向左滚动,滚动距离为负数,向右滚动,滚动距离为正数。所以,假如我们的整个动画场景的可滚动距离为x。则 min为-x,max为0。
剩下的疑问就是怎么计算这个可滚动距离??
以demo的四字魔咒举例,场景一二的长宽如下:
本来如果是简单的设计,整个舞台的长度就是场景一的长度加场景二的长度。
但是因为场景一与场景二存在叠加,母亲的精灵图要放置在窗户处。所以整个舞台的长度就是4971
从上图可以得出设计图的舞台总长度就是4971 ,但是实际的总长度经过缩放比的计算后有所变化,通过app.stage.width可以取得舞台的实际总长度。
所以,我们要知道舞台的实际总长度,不需要去计算设计图每个场景的长度,直接通过app.stage.width去获取就可以了。
知道了舞台的实际总长度,那么舞台的可滑动距离就是舞台的总长度减去一整屏的长度。(不知道这句话好理解不。。。。)
屏幕长度如何获取呢?
// 创建PIXI应用
const w = document.body.clientWidth,
h = document.body.clientHeight;
let app = new PIXI.Application({
width:w,
height:h,
backgroundColor:0xd7a664,
forceCanvas:true
});
// 获取屏幕宽高,判断横屏还是竖屏
const min = (wh)?w:h;
let scale = min/750; // 根据设计稿尺寸进行缩放比例调整
console.log(w,h,min,"放大系数:",scale);
这个max就是我们的屏幕长度,所以可滑动距离就是 app.stage.width - max
而min需要取负值,就是 -app.stage.width + max
剧烈抖动是因为可滚动距离计算错误,这个问题就回到了第一个问题。
我当初在写练手dmeo时,很多错误没有及时纠正,因为后续工作忙就没有去改正。我也没想料想到有那么多人关注我的博文。现在代码已经更正错误。
但是我还是建议大家看“春节温暖回家路”的作品的源代码。相信大家看了就会有疑问,为什么这个作品的min,反而是 -app.stage.width + max + max??因为这个作品的设计,第一屏是不允许滚动,而是通过一个点击事件,直接进入第二屏。所以可滚动距离会减掉两个屏的长度。
没有特殊设计,一般情况下就是-app.stage.width + max
delay:动画开始的时间
duration:动画持续的时间
在h5一镜到底中,时间的概念其实就是滑动距离。总滑动距离就是时间1。举个例子,假如可滑动的总距离是100m,滑动距离为20m时开始播放动画,即delay为 0.2。滑动到50m时停止播放动画,那么动画持续时间就是0.5-0.2=0.3
其实这个相对简单,我是建议大家不要去计算太麻烦了,直接通过console.log去获取。滑动的回调函数会实时返回滑动的距离,通过计算可得出当前的滑动进度progress。我们通过拖动,可以得出输出结果。拖动到要开始播放动画的地方,获取progress1,这个动画的delay就是progress;拖动到动画要停止的地方,获取progress2。所以动画的duration就是progress2-progress1
function initTouch(vertical,val){
let scrollDis = app.stage.width - max
console.log('舞台总长度', app.stage.width)
console.log('屏幕长度', max)
console.log('可滚动的距离', scrollDis)
alloyTouch = new AlloyTouch({
touch:"body",//反馈触摸的dom
vertical: vertical,//不必需,默认是true代表监听竖直方向touch
min: -scrollDis, //不必需,运动属性的最小值
maxSpeed: 1,
max: 0, //不必需,滚动属性的最大值
bindSelf: false,
initialValue: 0,
change:function(value){
app.stage.position[val] = value;
let progress = -value / scrollDis;
console.log(progress) // 这个console超级有用!!!!帮助我们得出动画的delay与duration!!!!!
progress = progress < 0 ? 0 : progress;
progress = progress > 1 ? 1 : progress;
timeline.seek(progress);
}
})
}