前言
接到一个需求,需要前端生成获取视频的缩略图,并且需要把多张图片拼接在一起,类似于剪辑软件时间轴的效果:
在服务端使用ffmpeg生成其实比较简单,但是别问为啥要前端来实现,问就是没空!
总体思路
首先想到的就是在浏览器端引入ffmpeg.wasm,但是这样会增大应用体积,如果没有其他视频处理的需求,还是尽量避免这个方案。
然后想到的是WebCodecs API,WebCodecs API是浏览器提供处理音视频的原生接口,但是只支持视频的编解码,不支持解封装,需要搭配mp4box.js使用,而且mp4box.js只支持mp4、mov格式的视频,所有也不考虑这个方案。
最后的方案是张鑫旭大神在使用JS快速获取video视频任意位置的缩略图提到的Video标签获取截图的方案,接下来详细了解一下
第一步、获取视频截图
代码实现
const handleGetVideoThumb = function (url, options = {}) {
if (typeof url != 'string') {
return;
}
const defaults = {
onLoading: () => {},
onLoaded: () => {},
onFinish: (arr) => {}
};
const params = Object.assign({}, defaults, options);
const video = document.createElement('video');
video.muted = true;
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d', {
willReadFrequently: true
});
let isTimeUpdated = false;
video.addEventListener('loadedmetadata', () => {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
draw();
});
video.addEventListener('timeupdate', () => {
isTimeUpdated = true;
});
params.onLoading();
if (/^blob:|base64,/i.test(url)) {
video.src = url;
} else {
fetch(url).then(res => res.blob()).then(blob => {
params.onLoaded();
video.src = URL.createObjectURL(blob);
});
}
const draw = () => {
const arrThumb = [];
const duration = video.duration;
let seekTime = 0.1;
const loop = () => {
if (isTimeUpdated) {
context.clearRect(0, 0, canvas.width, canvas.height);
context.drawImage(video, 0, 0, canvas.width, canvas.height);
canvas.toBlob(blob => {
arrThumb.push(URL.createObjectURL(blob));
seekTime += 1;
if (seekTime > duration) {
params.onFinish(arrThumb);
return;
}
step();
}, 'image/jpeg');
return;
}
requestAnimationFrame(loop);
}
const step = () => {
isTimeUpdated = false;
video.currentTime = seekTime;
loop();
}
step();
}
};
代码解析
handleGetVideoThumb
函数是实现视频截图功能的核心。它接受两个参数:视频的URL和可选的配置对象options
。
const handleGetVideoThumb = function (url, options = {}) {
if (typeof url != 'string') {
return;
}
const defaults = {
onLoading: () => {},
onLoaded: () => {},
onFinish: (arr) => {}
};
const params = Object.assign({}, defaults, options);
};
在这段代码中,首先检查url
是否为字符串类型,确保输入有效。然后,定义了默认的回调函数,并通过Object.assign
合并用户定义的选项。
视频元素与Canvas初始化
接下来,代码创建了视频元素video
和Canvas元素canvas
,用于加载视频和绘制缩略图:
const video = document.createElement('video');
video.muted = true;
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d', {
willReadFrequently: true
});
这里,video
元素被设置为静音,以防止自动播放时产生声音。canvas
的getContext
方法设置了willReadFrequently
选项,这有助于提高绘制性能。
视频加载与尺寸设置
视频加载过程中,通过监听loadedmetadata
事件来获取视频的尺寸,并设置canvas
的大小:
video.addEventListener('loadedmetadata', () => {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
draw();
});
缩略图绘制逻辑
draw
函数是绘制缩略图的核心,它定义了如何从视频中捕获帧并生成缩略图:
const draw = () => {
const loop = () => {
if (isTimeUpdated) {
context.clearRect(0, 0, canvas.width, canvas.height);
context.drawImage(video, 0, 0, canvas.width, canvas.height);
}
requestAnimationFrame(loop);
};
const step = () => {
video.currentTime = seekTime;
loop();
};
step();
}
在draw
函数中,使用requestAnimationFrame
创建了一个循环,该循环在视频的timeupdate
事件触发时执行。每次循环都会清除画布并重新绘制当前视频帧,然后生成缩略图的blob,并将其转换为URL。
视频数据获取
视频数据的获取处理了本地和远程URL的情况:
if (/^blob:|base64,/i.test(url)) {
video.src = url;
} else {
fetch(url).then(res => res.blob()).then(blob => {
params.onLoaded();
video.src = URL.createObjectURL(blob);
});
}
如果URL是本地文件或base64编码的URL,直接设置为视频源。对于远程URL,使用fetch
请求视频数据,并将其转换为blob对象,然后创建一个对象URL。
第二步、方案优化
如果直接使用这个方法截图会存在一些性能问题,比如:
视频的缩略图列表需要使用多个img标签展示,如果列表比较长或者需要同时展示多个缩略图列表,会使用到很多的img标签,造成性能问题
canvas.toBlob
比较耗时,而且需要等待图片生成完成,才进行下一张截图的生成,我们可以直接使用canvas展示图片内容,避免调用canvas.toBlob
和等待图片生成的耗时。
export const generateThumbnails = async (url: string, container: { width: number, height: number }): Promise<ImageBitmap> => {
return new Promise((resolve) => {
const video = document.createElement('video');
video.muted = true;
const offscreenCanvas = new OffscreenCanvas(container.width, container.height);
const ctx = offscreenCanvas.getContext('2d');
let isTimeUpdated = false;
video.addEventListener('loadedmetadata', () => {
const scale = container.height / video.videoHeight;
const total = Math.ceil(container.width / (video.videoWidth * scale));
const drawH = video.videoHeight * scale;
const drawW = video.videoWidth * scale;
let seekTime = 0.1;
const interval = (video.duration - seekTime) / total;
draw(interval, drawW, drawH, seekTime);
});
video.addEventListener('timeupdate', () => {
isTimeUpdated = true;
});
if (/^blob:|base64,/i.test(url)) {
video.src = url;
} else {
fetch(url).then(res => res.blob()).then(blob => {
video.src = URL.createObjectURL(blob);
});
}
const draw = (interval: number, drawW: number, drawH: number, seekTime: number) => {
const duration = video.duration;
let count = 0;
let currentTime = seekTime + interval * count;
const loop = () => {
if (isTimeUpdated && ctx) {
ctx.drawImage(video, count * drawW, 0, drawW, drawH);
currentTime = seekTime + interval * count;
count++;
if (currentTime > duration) {
resolve(offscreenCanvas.transferToImageBitmap());
return;
}
step();
return;
}
控状态
requestAnimationFrame(loop);
}
const step = () => {
isTimeUpdated = false;
video.currentTime = currentTime;
loop();
}
step();
}
});
}
代码解析
初始化和创建视频元素
在视频URL之外还会接收一个参数,用来接收容器的尺寸,后面我们需要根据视频尺寸判断需要绘制多少张缩略图
创建离屏画布元素
const offscreenCanvas = new OffscreenCanvas(container.width, container.height);
const ctx = offscreenCanvas.getContext('2d');
创建一个OffscreenCanvas
元素,并获取其2D绘图上下文;OffscreenCanvas
用于在后台线程绘制图形,可以提高性能。
加载视频并获取元数据
video.addEventListener('loadedmetadata', () => {
const scale = container.height / video.videoHeight;
const total = Math.ceil(container.width / (video.videoWidth * scale));
const drawH = video.videoHeight * scale;
const drawW = video.videoWidth * scale;
let seekTime = 0.1;
const interval = (video.duration - seekTime) / total;
draw(interval, drawW, drawH, seekTime);
});
在 loadedmetadata
事件中获取视频的宽度和高度,然后根据容器的尺寸计算缩略图的数量和每个缩略图的尺寸;再根据缩略图数量和视频时长计算每次截取视频帧的时间间隔。
seekTIme
设置为0.1是因为很多视频首帧没有内容,所有从0.1s开始进行截屏 。
绘制视频缩略图
const draw = (interval: number, drawW: number, drawH: number, seekTime: number) => {
const duration = video.duration;
let count = 0;
let currentTime = seekTime + interval * count;
const loop = () => {
if (isTimeUpdated && ctx) {
ctx.drawImage(video, count * drawW, 0, drawW, drawH);
currentTime = seekTime + interval * count;
count++;
if (currentTime > duration) {
resolve(offscreenCanvas.transferToImageBitmap());
return;
}
step();
return;
}
requestAnimationFrame(loop);
}
const step = () => {
isTimeUpdated = false;
video.currentTime = currentTime;
loop();
}
step();
}
draw
函数的整体结构没有改变,主要修改是为:
总结
实际测试下来,生成耗时有些许提升,对于这个方案耗时影响比较大的因素,还是视频加载。

测试Demo地址:生成视频缩略图
相对于后端生成,前端生成缩略图的方案,在处理本地视频文件场景时还是比较合适,不需要将视频上传到服务器就可以获取到,因为视频资源就在本地,所以不需要把时间消耗在资源加载上;但是如果是网络资源,就会受到用户网络的限制,而且如果视频资源无法使用Video标签播放或者不允许跨域,我们也没有办法获取到缩略图。
该文章在 2024/8/7 10:48:56 编辑过