背景
在个人开发中,想在微信 h5 里面做一个大画幅的背景视频,起初直接通过 video 标签引入,然后无限播放。
但是这里有个问题,那就是很不连贯,也就是说在结束之后会有刹那的停顿。
就在想,我要让它能够播放到最后,在从最后往前走的方式来走。
思路
实现视频 "正放→倒放→正放→倒放..." 无限循环播放的核心思路,是通过视频帧捕捉 + Canvas 绘制 + 定时器控制,结合 "正放时记录帧、结束后倒放帧、倒放结束后重新正放" 的逻辑闭环实现的。(老实脸,该代码由 ai 生成,谢谢惠顾)
过程
请打开 ai 软件,然后输入以下提示词:
"我现在想在微信网页端去实现视频"正放→倒放→正放→倒放..."无限循环播放的效果,你帮我基于 Taro + vue3 setup(可替换任意技术栈) 来实现,谢谢"
如果想要深入了解,请输入以下提示词: "帮我解释一下这个实现思路。"
这里并不是我懒,而是我各人期望大家能多用 ai 去解决问题,事实上,自从 ai 起来了以后,我已经很久没有用过搜索引擎了。。。。。
但是 ai 却也不是万能的,对于真实现实世界,它的触达率还是欠缺的(当然或许跟我没有氪金有关),总之我还是遇到了一个问题
遇到的问题
为什么微信h5打开直接就是黑屏了?
在我第五次在普通浏览器打开,发现视频的"正放→倒放→正放→倒放..."无限循环如期发生的时候,我陷入了迷茫。
我列出来如下的可能性去排查:
-
在微信网页里面的视频有问题,无法读取?
并不是,我直接在微信点击了视频链接,可以访问
-
机制不对?初始化的时间不对?
在我延时 2 秒后执行,奇迹发生了,逻辑正常了。
所以关键来了,需要在视频准备好的时候去播放,在于要在 video 的 @loadedmetadata,也就是视频元数据加载完成的时机,去触发才对。
代码示例
⚠️注意:下面的代码是基于 vue3 + taro 实现的,跟纯 h5 存在差异
vue
<template>
<view class="video-wrapper" :data-bg-video-src="bgVideoSrc">
<button class="play-btn" @tap="handlePlayBtnClick">触发播放</button>
<video
:id="videoId"
:src="bgVideoSrc"
class="hidden-video"
:muted="true"
:controls="false"
autoplay
:loop="false"
webkit-playsinline
crossorigin="anonymous"
playsinline
@loadedmetadata="onVideoReady"
@ended="onVideoEnded"
@error="onVideoError"
/>
<canvas :id="canvasId" class="fullscreen-canvas"></canvas>
</view>
</template>
<script setup>
import { onMounted, ref, nextTick, onUnmounted } from "vue";
import bgVideoSrc from "@/assets/images/annual-report-2025/index-bg-078.mp4";
// 生成唯一ID
const generateUniqueId = () => {
return `bg-video-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
};
const videoId = generateUniqueId();
const canvasId = generateUniqueId();
const videoRef = ref(null);
const canvasRef = ref(null);
const ctxRef = ref(null);
// 视频相关状态
const frameList = ref([]);
const fps = 30;
let captureInterval = null;
let reverseTimer = null;
let direction = "forward"; // forward 正放, reverse 倒放
// 初始化视频播放
const initVideoPlayback = async () => {
try {
frameList.value = [];
await nextTick();
// 获取video和canvas元素
videoRef.value = document.getElementById(videoId)?.querySelector("video");
canvasRef.value = document
.getElementById(canvasId)
?.querySelector("canvas");
if (!videoRef.value || typeof videoRef.value.play !== "function") {
console.error("video 引用无效或不支持 play:", videoRef.value);
return;
}
if (!canvasRef.value) {
console.error("canvas 引用无效:", canvasRef.value);
return;
}
ctxRef.value = canvasRef.value.getContext("2d");
videoRef.value.currentTime = 0;
direction = "forward";
videoRef.value.play();
startCaptureFrames();
} catch (error) {
console.error("初始化视频播放失败:", error);
}
};
// 开始捕捉帧
const startCaptureFrames = () => {
if (!videoRef.value || !canvasRef.value || !ctxRef.value) return;
captureInterval = setInterval(() => {
try {
const canvas = canvasRef.value;
const ctx = ctxRef.value;
const video = videoRef.value;
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const frame = ctx.getImageData(0, 0, canvas.width, canvas.height);
frameList.value.push(frame);
} catch (error) {
console.warn("无法捕捉帧:", error);
}
}, 1000 / fps);
};
// 停止捕捉帧
const stopCaptureFrames = () => {
if (captureInterval) {
clearInterval(captureInterval);
captureInterval = null;
}
};
// 播放倒放帧
const playReverseFrames = () => {
if (!ctxRef.value || frameList.value.length === 0) return;
let frameIndex = frameList.value.length - 1;
const ctx = ctxRef.value;
reverseTimer = setInterval(() => {
if (frameIndex < 0) {
clearInterval(reverseTimer);
reverseTimer = null;
// 倒放结束 → 再次正放
direction = "forward";
videoRef.value.currentTime = 0;
frameList.value = [];
videoRef.value.play();
startCaptureFrames();
return;
}
try {
ctx.putImageData(frameList.value[frameIndex], 0, 0);
frameIndex--;
} catch (error) {
console.warn("播放倒放帧失败:", error);
}
}, 1000 / fps);
};
// 视频结束时触发
function onVideoEnded() {
stopCaptureFrames();
if (direction === "forward") {
// 正放结束 → 倒放
direction = "reverse";
playReverseFrames();
} else {
// 倒放结束(理论上不会到这里,因为 playReverseFrames 内部会切回正放)
direction = "forward";
videoRef.value.currentTime = 0;
videoRef.value.play();
startCaptureFrames();
}
}
function handlePlayBtnClick() {
initVideoPlayback();
}
function onVideoReady() {
console.log('视频加载完成');
initVideoPlayback();
}
function onVideoError(error) {
console.error("视频加载错误:", error);
}
// 清理资源
const cleanup = () => {
stopCaptureFrames();
if (reverseTimer) {
clearInterval(reverseTimer);
reverseTimer = null;
}
frameList.value = [];
videoRef.value = null;
canvasRef.value = null;
ctxRef.value = null;
};
onUnmounted(() => {
cleanup();
});
</script>
<style lang="less" scoped>
.de-bug{
.play-btn{
position: absolute;
top: 0;
left: 0;
z-index: 8;
}
.hidden-video{
z-index: 5;
opacity: 1;
}
.fullscreen-canvas{
border: 5px solid red;
}
}
.video-wrapper {
position: relative;
width: 100vw;
height: 100vh;
overflow: hidden;
}
.hidden-video {
position: absolute;
width: 100%;
height: 100%;
object-fit: fill;
z-index: -1;
opacity: 0;
}
.fullscreen-canvas {
position: absolute;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
object-fit: fill;
background: black;
z-index: 1;
}
</style>