核心需求是在网页或应用界面上叠加一个动画效果(礼物),这个动画需要有透明背景,以便能看到下方的直播视频或其他内容。
挑战:
标准的 MP4 (H.264/AVC) 视频编码格式本身不直接支持 Alpha 通道(透明度)。因此,需要采用特殊的技术来实现透明效果。
方法一:使用 yyeva 库(视频转 Canvas)
yyeva 是一个由 YY(欢聚时代)开发的解决方案,专门用于在移动端和 Web 端高效播放带 Alpha 通道的 MP4 视频。它的原理通常是:
- 视频编码: 使用特定的工具(如他们提供的
yyeva-tool或类似工具)将原始带透明通道的素材(如图序列、AE 导出的带 Alpha 的视频等)编码成一个特殊的 MP4 文件。这个 MP4 文件会将 RGB(颜色)信息和 Alpha(透明度)信息编码在一起。常见的做法是将视频帧分割成两部分,一部分存 RGB,另一部分存 Alpha(例如左右分割或上下分割)。 - 前端解码与渲染: 使用
yyeva的 JavaScript 库 (yyeva.js) 在前端加载这个特殊的 MP4 文件。 - Canvas 绘制:
yyeva.js库在内部解码视频,逐帧提取 RGB 和 Alpha 信息,然后使用 WebGL(优先,性能更好)或 Canvas 2D API 将合成后的、带透明效果的帧绘制到指定的<canvas>元素上。
优点:
- 性能较好: 针对移动端和 Web 优化,利用 GPU 加速(WebGL)。
- 效果还原度高: 可以很好地还原复杂的视频动画效果。
- 生态相对成熟: 有配套的转换工具和文档支持。
缺点:
- 需要特定工具: 必须使用其配套工具进行视频转换,增加了工作流环节。
- 依赖库: 需要引入
yyeva.js这个库。 - 兼容性: 虽然目标是跨平台,但具体效果和性能可能受设备和浏览器 WebGL/Canvas 支持程度的影响。
yyeva 使用步骤与代码示例:
-
准备视频:
- 你需要使用
yyeva提供的工具链将你的动画源文件(例如带透明通道的 MOV 文件、PNG 序列)转换为yyeva格式的 MP4 文件。这个过程通常在命令行或通过其提供的 GUI 工具完成。假设你已经得到了一个名为gift_animation.mp4的yyeva格式视频。
- 你需要使用
-
安装
yyeva库:-
通过 npm/yarn (推荐):
Bash
npm install yyeval --save # 或者 yarn add yyeval -
通过
<script>标签: 从yyeva的官方渠道获取yyeva.min.js文件,并在 HTML 中引入。<script src="path/to/yyeva.min.js"></script>
-
-
HTML 结构:
准备一个用于渲染动画的 元素。为了定位,通常会把它放在一个容器 div 中。
<!DOCTYPE html> <html> <head> <title>YYEVA Gift Animation Demo</title> <style> body { background-color: lightblue; /* 用于演示透明效果 */ margin: 0; padding: 0; position: relative; height: 100vh; overflow: hidden; /* 防止滚动条 */ } #live-background { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: linear-gradient(to bottom right, #ffcccc, #ccffcc); /* 模拟直播背景 */ display: flex; justify-content: center; align-items: center; font-size: 2em; color: white; text-shadow: 1px 1px 2px black; } #gift-container { position: absolute; /* 定位礼物动画的位置 */ bottom: 50px; left: 50%; transform: translateX(-50%); width: 300px; /* 根据动画实际尺寸调整 */ height: 300px; /* 根据动画实际尺寸调整 */ z-index: 10; /* 确保在背景之上 */ } #gift-canvas { display: block; /* 避免 canvas 下方有空隙 */ width: 100%; height: 100%; } </style> </head> <body> <div id="live-background"> 模拟直播内容区域 </div> <div id="gift-container"> <canvas id="gift-canvas"></canvas> </div> <script type="module"> // 如果使用 npm/yarn 安装 // 详细的 JS 代码见下方 import { init, play, stop, destroy } from './yyeva-player-logic.js'; // 假设逻辑分离到单独文件 document.addEventListener('DOMContentLoaded', () => { const container = document.getElementById('gift-container'); const canvas = document.getElementById('gift-canvas'); const videoUrl = './assets/gift_animation.mp4'; // 替换为你的 yeva 视频路径 if (!container || !canvas) { console.error('Cannot find container or canvas element.'); return; } // 初始化播放器实例 const yevaPlayer = init(container, canvas, videoUrl, { loop: false, // 是否循环播放 verbose: true, // 是否打印详细日志 useWorker: true, // 是否使用 Web Worker 进行解码(推荐,防止阻塞主线程) // ... 其他 yeva 配置项 }); if (yevaPlayer) { // 示例:延迟 1 秒后播放动画 setTimeout(() => { console.log('Attempting to play gift animation...'); play(yevaPlayer); }, 1000); // 可以在需要的时候停止 // setTimeout(() => { // console.log('Stopping gift animation...'); // stop(yevaPlayer); // }, 5000); // 页面卸载或不再需要时销毁实例,释放资源 window.addEventListener('beforeunload', () => { destroy(yevaPlayer); }); } }); </script> </body> </html> -
JavaScript 实现 (
yyeva-player-logic.js- 示例):// yeva-player-logic.js // 假设你通过 npm 安装了 yyeval import YYEVA from 'yyeva'; // 或根据实际导出方式调整 let playerInstance = null; /** * 初始化 YYEVAPlayer * @param {HTMLElement} container - 播放器容器元素 * @param {HTMLCanvasElement} canvas - 用于渲染的 canvas 元素 * @param {string} videoUrl - yeva 格式视频的 URL * @param {object} options - yeva 播放器配置项 * @returns {object | null} 返回播放器实例或 null (如果初始化失败) */ export function init(container, canvas, videoUrl, options = {}) { if (!container || !canvas || !videoUrl) { console.error('[YYEVA Logic] Invalid parameters for init.'); return null; } // 确保销毁之前的实例(如果存在) if (playerInstance) { console.warn('[YYEVA Logic] Destroying previous player instance before initializing a new one.'); destroy(playerInstance); } console.log('[YYEVA Logic] Initializing YYEVAPlayer with options:', options); try { const mergedOptions = { container: container, // 播放器容器 videoUrl: videoUrl, // 视频地址 canvas: canvas, // 指定渲染的 canvas loop: options.loop ?? false, // 是否循环播放,默认为 false autoplay: false, // 通常不自动播放,由外部控制何时播放 verbose: options.verbose ?? false, // 是否输出详细日志 useWorker: options.useWorker ?? true, // 是否使用 worker 解码 // transparent: true, // yeva 默认就是处理透明的,这个选项可能不需要或有特定含义,查阅文档 renderType: options.renderType ?? 'webgl', // 优先使用 'webgl',可回退到 'canvas2d' mute: options.mute ?? true, // 礼物通常是静音的 // ... 其他 yeva 支持的配置项,请参考官方文档 }; playerInstance = new YYEVA(mergedOptions); // --- 事件监听 --- playerInstance.on('play', () => { console.log('[YYEVA Logic] Event: Play'); // 可以在这里触发一些 UI 变化,比如显示一个“正在播放”的状态 }); playerInstance.on('pause', () => { console.log('[YYEVA Logic] Event: Pause'); }); playerInstance.on('stop', () => { console.log('[YYEVA Logic] Event: Stop'); // 动画停止时(非暂停) }); playerInstance.on('ended', () => { console.log('[YYEVA Logic] Event: Ended'); // 动画播放完成(如果 loop 为 false) // 可以在这里隐藏 canvas 或销毁实例 // destroy(playerInstance); // 如果播放完就销毁 }); playerInstance.on('error', (errorData) => { console.error('[YYEVA Logic] Event: Error', errorData); // 处理加载或播放错误 // 可能需要向用户显示错误信息或尝试重新加载 // destroy(playerInstance); // 出错时通常也需要清理 }); playerInstance.on('decoded', () => { // console.log('[YYEVA Logic] Event: Frame Decoded'); // 这个事件可能会非常频繁 }); playerInstance.on('complete', () => { console.log('[YYEVA Logic] Event: Video processing complete (load/decode setup finished)'); // 这个事件通常表示视频已准备好,可以调用 play() // 如果 autoplay 为 false,可以在这里触发播放,或者通知外部可以播放了 // play(playerInstance); // 如果希望加载完成后立即播放 }); console.log('[YYEVA Logic] YYEVAPlayer instance created.'); return playerInstance; } catch (error) { console.error('[YYEVA Logic] Failed to initialize YYEVAPlayer:', error); playerInstance = null; return null; } } /** * 播放动画 * @param {object} instance - YYEVAPlayer 实例 */ export function play(instance) { if (instance && typeof instance.play === 'function') { console.log('[YYEVA Logic] Calling play()...'); instance.play(); } else { console.error('[YYEVA Logic] Invalid instance provided to play() or instance is not initialized.'); } } /** * 暂停动画 * @param {object} instance - YYEVAPlayer 实例 */ export function pause(instance) { if (instance && typeof instance.pause === 'function') { console.log('[YYEVA Logic] Calling pause()...'); instance.pause(); } else { console.error('[YYEVA Logic] Invalid instance provided to pause().'); } } /** * 停止动画(通常会重置到第一帧) * @param {object} instance - YYEVAPlayer 实例 */ export function stop(instance) { if (instance && typeof instance.stop === 'function') { console.log('[YYEVA Logic] Calling stop()...'); instance.stop(); } else { console.error('[YYEVA Logic] Invalid instance provided to stop().'); } } /** * 销毁播放器实例,释放资源 * @param {object} instance - YYEVAPlayer 实例 */ export function destroy(instance) { if (instance && typeof instance.destroy === 'function') { console.log('[YYEVA Logic] Calling destroy()...'); try { instance.destroy(); if (instance === playerInstance) { playerInstance = null; // 清除全局引用 console.log('[YYEVA Logic] Player instance destroyed and reference cleared.'); } else { console.log('[YYEVA Logic] Player instance destroyed (was not the main tracked instance).'); } } catch (error) { console.error('[YYEVA Logic] Error during destroy():', error); } } else if (instance) { console.warn('[YYEVA Logic] Instance provided to destroy() seems invalid or lacks a destroy method.'); } // else { // console.log('[YYEVA Logic] No instance to destroy.'); // } } // 可以在这里添加更多控制函数,例如: // - seek(time) // - setMute(muted) // - setLoop(loop) // - getCurrentTime() // ...具体接口请查阅 yyeval 官方文档
注意: 上述 yyeva 代码是基于假设的 API 结构编写的,你需要参考 yyeva 官方文档来确认具体的类名、方法名、配置项和事件名。逻辑分离到 yyeva-player-logic.js 是为了更好的代码组织。
方法二:使用 WebM 格式(VP8/VP9 with Alpha)
WebM 是一个开放、免版税的媒体文件格式,其视频编解码器 VP8 和 VP9 都支持 Alpha 通道。现代浏览器大多可以直接在 <video> 标签中播放带 Alpha 通道的 WebM 文件。
优点:
- 标准化: WebM 是 W3C 推荐的网页视频格式之一。
- 无需额外 JS 库(用于播放): 浏览器原生支持,使用
<video>标签即可。 - 开源工具支持: 可以使用 FFmpeg 等工具进行转换。
缺点:
- 编码可能较慢: VP9 编码可能比 H.264 慢。
- 文件大小: 对于复杂动画,文件大小可能比优化过的
yyeva或 Lottie 更大。 - 兼容性: 虽然主流浏览器支持良好,但仍需考虑非常老的浏览器或特定环境。Safari 对 VP9 的支持相对较晚(但现代版本已支持)。
使用步骤与代码示例:
-
准备视频:
-
使用支持导出 Alpha 通道的视频编辑软件(如 Adobe After Effects, Premiere Pro)导出带透明背景的视频(通常是 QuickTime 格式,使用 Animation 或 ProRes 4444 编码)。
-
使用 FFmpeg 将其转换为 WebM (VP9 with Alpha):
# 示例 FFmpeg 命令 ffmpeg -i input_with_alpha.mov \ -c:v libvpx-vp9 `# 使用 VP9 编码器` \ -pix_fmt yuva420p `# 指定包含 Alpha 的像素格式` \ -b:v 1M `# 设置视频比特率 (根据需要调整)` \ -auto-alt-ref 0 `# 一些 VP9 参数,有助于 Alpha 编码` \ -metadata:s:v:0 alpha_mode="1" `# 添加 Alpha 元数据` \ -an `# 去除音频 (如果不需要)` \ output_gift.webm注意: FFmpeg 参数可能需要根据源视频和期望质量进行调整。
-pix_fmt yuva420p是常见的带 Alpha 的格式,但也可能有其他选项如yuva444p。
-
-
HTML 结构:
直接使用 标签。
<!DOCTYPE html> <html> <head> <title>WebM Alpha Animation Demo</title> <style> /* 同上一个示例的 CSS */ body { background-color: lightcoral; margin: 0; padding: 0; position: relative; height: 100vh; overflow: hidden; } #live-background { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: linear-gradient(to bottom right, #ddeeff, #eeddff); display: flex; justify-content: center; align-items: center; font-size: 2em; color: white; text-shadow: 1px 1px 2px black; } #gift-container { position: absolute; bottom: 100px; right: 50px; width: 250px; /* 调整尺寸 */ height: 250px; /* 调整尺寸 */ z-index: 10; } #gift-video { width: 100%; height: 100%; display: block; object-fit: contain; /* 或 cover, fill 根据需要 */ } </style> </head> <body> <div id="live-background"> 模拟直播内容区域 </div> <div id="gift-container"> <video id="gift-video" src="./assets/output_gift.webm" autoplay `` muted `` loop `` playsinline `` preload="auto" `` style="pointer-events: none;" `` > Your browser does not support the video tag or WebM with alpha. </video> </div> <div style="position: absolute; top: 10px; left: 10px; z-index: 20;"> <button id="playBtn">Play</button> <button id="pauseBtn">Pause</button> <button id="resetBtn">Reset & Play</button> </div> <script> document.addEventListener('DOMContentLoaded', () => { const videoElement = document.getElementById('gift-video'); const playBtn = document.getElementById('playBtn'); const pauseBtn = document.getElementById('pauseBtn'); const resetBtn = document.getElementById('resetBtn'); if (!videoElement) { console.error('Video element not found'); return; } // --- 基本事件监听 --- videoElement.addEventListener('loadedmetadata', () => { console.log('Video metadata loaded. Duration:', videoElement.duration); }); videoElement.addEventListener('canplay', () => { console.log('Video can start playing.'); // 如果没有设置 autoplay,可以在这里调用 play() // videoElement.play().catch(e => console.error('Autoplay prevented:', e)); }); videoElement.addEventListener('ended', () => { console.log('Video playback ended.'); // 如果 loop=false,可以在这里处理播放完成的逻辑 // 例如隐藏视频或容器 // videoElement.parentElement.style.display = 'none'; }); videoElement.addEventListener('error', (event) => { console.error('Video error:', event); const error = videoElement.error; if (error) { console.error('Error code:', error.code); console.error('Error message:', error.message); } // 可能需要显示错误提示 }); // --- 控制功能 --- if (playBtn) { playBtn.addEventListener('click', () => { videoElement.play().catch(e => console.error('Play failed:', e)); console.log('Play button clicked'); }); } if (pauseBtn) { pauseBtn.addEventListener('click', () => { videoElement.pause(); console.log('Pause button clicked'); }); } if (resetBtn) { resetBtn.addEventListener('click', () => { videoElement.currentTime = 0; // 回到开头 videoElement.play().catch(e => console.error('Play after reset failed:', e)); console.log('Reset & Play button clicked'); }); } // --- 动态加载和播放示例 --- function playGiftAnimation(webmUrl) { console.log(`Loading and playing gift: ${webmUrl}`); // 确保容器可见 (如果之前隐藏了) const container = document.getElementById('gift-container'); if (container) container.style.display = 'block'; // 设置新的源并播放 videoElement.src = webmUrl; videoElement.load(); // 告诉浏览器加载新源 videoElement.play().catch(e => { console.error(`Failed to play ${webmUrl}:`, e); // 可能是因为用户未与页面交互,浏览器阻止了自动播放 // 可以提示用户点击播放 }); } // 示例:点击按钮触发另一个礼物动画 const triggerButton = document.createElement('button'); triggerButton.textContent = 'Play Another Gift (Example)'; triggerButton.style.position = 'absolute'; triggerButton.style.top = '50px'; triggerButton.style.left = '10px'; triggerButton.style.zIndex = '20'; triggerButton.onclick = () => { // 假设有另一个 WebM 文件 playGiftAnimation('./assets/another_gift.webm'); }; document.body.appendChild(triggerButton); }); </script> </body> </html>
方法三:使用 Lottie (Bodymovin)
Lottie 是 Airbnb 开源的一个库,用于解析 Adobe After Effects 通过 Bodymovin 插件导出的 JSON 动画,并在 Web、iOS、Android 和 React Native 上本地渲染。它非常适合矢量动画,文件小,性能好,并且支持透明度。
优点:
- 跨平台: 一套 AE 动画,多端渲染。
- 高性能: 通常使用 SVG 或 Canvas/WebGL 渲染,性能优异。
- 文件小: 基于 JSON 的矢量描述,文件体积通常远小于视频。
- 可交互性: 可以通过 JS 控制动画播放、速度、进度,甚至响应用户交互。
- 质量高: 矢量动画无损缩放。
- 成熟生态: 广泛使用,文档和社区支持良好。
缺点:
- 依赖 AE 和 Bodymovin: 动画制作流程绑定了 After Effects。
- 不适合复杂视频效果: 对于包含大量光影、粒子、真实视频片段的动画,Lottie 可能不是最佳选择(虽然也能嵌入位图)。
- 需要 JS 库: 需要引入
lottie-web库。
使用步骤与代码示例:
-
准备动画:
- 在 Adobe After Effects 中创建动画。
- 安装 Bodymovin 插件。
- 使用 Bodymovin 将 AE 合成导出为
.json文件。确保导出设置中包含了需要的特性,并且没有使用 Lottie 不支持的 AE 特效。
-
安装
lottie-web库:-
通过 npm/yarn:
npm install lottie-web --save # 或者 yarn add lottie-web -
通过 CDN 或下载 JS 文件:
<script src="https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.12.2/lottie.min.js"></script> ```
-
-
HTML 结构:
准备一个 div 作为 Lottie 动画的容器。
<!DOCTYPE html> <html> <head> <title>Lottie Animation Demo</title> <style> /* 同上一个示例的 CSS */ body { background-color: #e0f7fa; margin: 0; padding: 0; position: relative; height: 100vh; overflow: hidden; } #live-background { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: url('https://via.placeholder.com/800x600/cccccc/888888?text=Simulated+Live+Stream') center/cover no-repeat; display: flex; justify-content: center; align-items: center; font-size: 2em; color: white; text-shadow: 1px 1px 2px black; } #lottie-gift-container { position: absolute; /* 居中显示示例 */ top: 50%; left: 50%; transform: translate(-50%, -50%); width: 400px; /* 根据动画内容调整 */ height: 400px; /* 根据动画内容调整 */ z-index: 10; /* background-color: rgba(255, 0, 0, 0.2); */ /* 可选:调试用背景色 */ } </style> <script src="https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.12.2/lottie.min.js"></script> </head> <body> <div id="live-background"> 模拟直播内容区域 </div> <div id="lottie-gift-container"> </div> <script> document.addEventListener('DOMContentLoaded', () => { const animationContainer = document.getElementById('lottie-gift-container'); let animationInstance = null; // 用于存储 Lottie 动画实例 if (!animationContainer) { console.error('Lottie container element not found.'); return; } /** * 加载并播放 Lottie 动画 * @param {string} animationPath - Lottie JSON 文件的路径 * @param {object} options - Lottie 配置选项 */ function playLottieAnimation(animationPath, options = {}) { // 如果已有实例,先销毁 if (animationInstance) { console.log('Destroying previous Lottie instance.'); try { animationInstance.destroy(); } catch(e) { console.error("Error destroying previous lottie instance", e); } animationInstance = null; // 清空容器,以防旧的 SVG/Canvas 元素残留 animationContainer.innerHTML = ''; } console.log(`Loading Lottie animation from: ${animationPath}`); try { animationInstance = lottie.loadAnimation({ container: animationContainer, // 动画容器 renderer: options.renderer || 'svg', // 渲染器: 'svg', 'canvas', 'html' loop: options.loop ?? false, // 是否循环 autoplay: options.autoplay ?? true, // 是否自动播放 path: animationPath, // JSON 文件路径 // animationData: jsonData, // 或者直接传入 JSON 数据对象 rendererSettings: options.rendererSettings || { // SVG 渲染器的设置示例 // progressiveLoad: true, // hideOnTransparent: true, // preserveAspectRatio: 'xMidYMid meet' }, // ... 其他 Lottie 配置项,参考 lottie-web 文档 }); console.log('Lottie animation loaded. Instance:', animationInstance); // --- 事件监听 --- animationInstance.addEventListener('complete', () => { console.log('Lottie animation completed (loop iteration finished or animation ended)'); // 如果 loop = false, 动画播放完成会触发 if (!animationInstance.loop) { console.log('Animation finished (non-looping).'); // 可以隐藏容器或销毁实例 // animationContainer.style.display = 'none'; // animationInstance.destroy(); // animationInstance = null; } }); animationInstance.addEventListener('loopComplete', () => { console.log('Lottie animation loop completed'); // 每次循环结束时触发 (如果 loop = true) }); animationInstance.addEventListener('DOMLoaded', () => { console.log('Lottie DOM loaded (SVG/Canvas ready).'); // 此时可以安全地操作动画实例了(如果 autoplay=false, 在这里可以 play()) }); animationInstance.addEventListener('data_ready', () => { console.log('Lottie animation data is ready.'); }); animationInstance.addEventListener('error', (error) => { console.error('Lottie error event:', error); // 处理加载或渲染错误 }); animationInstance.addEventListener('destroy', () => { console.log('Lottie instance destroyed.'); if (animationInstance === lottie.getAnimationByID(animationInstance.animationID)) { // Ensure instance is nulled out if it was the active one animationInstance = null; } }); } catch (error) { console.error('Failed to load Lottie animation:', error); animationInstance = null; } } // --- 控制函数示例 --- function pauseLottie() { if (animationInstance) { animationInstance.pause(); console.log('Lottie paused.'); } } function resumeLottie() { if (animationInstance) { animationInstance.play(); console.log('Lottie resumed.'); } } function stopLottie() { if (animationInstance) { animationInstance.stop(); // 停止并回到第一帧 console.log('Lottie stopped.'); } } function setLottieSpeed(speed) { if (animationInstance) { animationInstance.setSpeed(speed); console.log(`Lottie speed set to ${speed}`); } } function goToFrameAndPlay(frame) { if (animationInstance) { animationInstance.goToAndPlay(frame, true); // 第二个参数 true 表示 frame number console.log(`Lottie jumping to frame ${frame} and playing.`); } } // --- 初始加载 --- const initialGiftPath = './assets/gift_animation.json'; // 替换为你的 Lottie JSON 路径 playLottieAnimation(initialGiftPath, { loop: false, autoplay: true, renderer: 'svg' // 优先使用 SVG,通常效果好且清晰 }); // --- 示例:添加控制按钮 --- const controlsDiv = document.createElement('div'); controlsDiv.style.position = 'absolute'; controlsDiv.style.top = '10px'; controlsDiv.style.right = '10px'; controlsDiv.style.zIndex = '20'; controlsDiv.style.background = 'rgba(255,255,255,0.7)'; controlsDiv.style.padding = '5px'; const pauseBtn = document.createElement('button'); pauseBtn.textContent = 'Pause'; pauseBtn.onclick = pauseLottie; const resumeBtn = document.createElement('button'); resumeBtn.textContent = 'Resume'; resumeBtn.onclick = resumeLottie; const stopBtn = document.createElement('button'); stopBtn.textContent = 'Stop'; stopBtn.onclick = stopLottie; const speedUpBtn = document.createElement('button'); speedUpBtn.textContent = 'Speed x2'; speedUpBtn.onclick = () => setLottieSpeed(2); const speedNormalBtn = document.createElement('button'); speedNormalBtn.textContent = 'Speed x1'; speedNormalBtn.onclick = () => setLottieSpeed(1); const playAnotherBtn = document.createElement('button'); playAnotherBtn.textContent = 'Play Rocket Gift'; playAnotherBtn.onclick = () => { playLottieAnimation('./assets/rocket_gift.json', { loop: false, autoplay: true }); // 假设有另一个 json }; controlsDiv.appendChild(pauseBtn); controlsDiv.appendChild(resumeBtn); controlsDiv.appendChild(stopBtn); controlsDiv.appendChild(speedUpBtn); controlsDiv.appendChild(speedNormalBtn); controlsDiv.appendChild(playAnotherBtn); document.body.appendChild(controlsDiv); // 页面卸载时清理 window.addEventListener('beforeunload', () => { if (animationInstance) { animationInstance.destroy(); } }); }); </script> </body> </html>
方法四:CSS Animations / Sprites
对于相对简单的动画(例如图标飞入、放大、旋转、淡出,或者基于雪碧图的帧动画),可以直接使用 CSS Animations。
优点:
- 原生,性能好: 浏览器对 CSS 动画(尤其是
transform和opacity)有很好的优化。 - 无需 JS 库: 动画本身用 CSS 定义。
- 简单直观: 对于常见的 UI 动画效果,CSS 代码易于理解。
缺点:
- 不适合复杂动画: 难以实现视频级别的复杂动态效果和细节。
- 雪碧图管理: 如果使用雪碧图帧动画,需要工具生成雪碧图和对应的 CSS,且图片可能较大。
- 控制有限: 通过 JS 控制 CSS 动画(如暂停在特定帧、变速)相对 Lottie 或 Canvas API 要麻烦一些。
使用步骤与代码示例 (雪碧图帧动画):
-
准备资源:
- 将动画的每一帧导出为单独的图片。
- 使用工具 (如 TexturePacker, 或者在线工具, 或手写脚本) 将这些帧合并成一张大的雪碧图 (spritesheet),并得到每一帧在雪碧图上的位置信息 (通常是 CSS
background-position)。假设我们有一张gift_sprite.png,包含 10 帧,每帧 100x100 像素,水平排列 (总宽 1000px)。
-
HTML 结构:
一个 div 用来显示动画。
<!DOCTYPE html> <html> <head> <title>CSS Sprite Animation Demo</title> <style> body { background-color: #f0e68c; margin: 0; padding: 0; position: relative; height: 100vh; overflow: hidden; } #live-background { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: linear-gradient(45deg, #6a82fb, #fc5c7d); display: flex; justify-content: center; align-items: center; font-size: 2em; color: white; text-shadow: 1px 1px 2px black; } .gift-sprite-container { position: absolute; bottom: 20px; left: 20px; width: 100px; /* 单帧宽度 */ height: 100px; /* 单帧高度 */ z-index: 10; /* background-color: rgba(0, 255, 0, 0.2); */ /* 调试用 */ overflow: hidden; /* 隐藏雪碧图中不需要的部分 */ } .gift-sprite-anim { width: 100%; height: 100%; background-image: url('./assets/gift_sprite.png'); /* 雪碧图路径 */ background-repeat: no-repeat; /* animation: playSprite 1s steps(10) infinite; */ /* steps(N), N = 帧数 */ /* 初始不播放,由 JS 触发 */ background-position: 0 0; /* 初始显示第一帧 */ } /* 定义动画 */ @keyframes playSprite { from { background-position: 0 0; } /* 雪碧图总宽度 1000px (10 帧 * 100px/帧) */ /* 移动背景,使其显示从第 1 帧到第 10 帧 */ /* 注意:是向左移动背景图,所以是负值 */ to { background-position: -1000px 0; } } /* 定义一个播放类,由 JS 添加/移除 */ .gift-sprite-anim.play { /* 动画名 时长 步进函数(帧数) 播放次数 */ animation: playSprite 1s steps(10) 1; /* 播放一次 */ /* animation: playSprite 1s steps(10) infinite; */ /* 无限循环 */ animation-fill-mode: forwards; /* 动画结束后保持最后一帧的状态 */ } /* 也可以添加其他组合动画,例如飞入效果 */ .gift-sprite-container.fly-in { animation: flyInEffect 0.5s ease-out forwards; } @keyframes flyInEffect { from { transform: translateY(150%) scale(0.5); opacity: 0; } to { transform: translateY(0) scale(1); opacity: 1; } } </style> </head> <body> <div id="live-background"> 模拟直播内容区域 </div> <div id="gift-area"> </div> <button id="triggerGiftBtn" style="position: absolute; top: 10px; left: 10px; z-index: 20;">Trigger CSS Gift</button> <script> document.addEventListener('DOMContentLoaded', () => { const giftArea = document.getElementById('gift-area'); const triggerBtn = document.getElementById('triggerGiftBtn'); if (!giftArea || !triggerBtn) { console.error('Required elements not found.'); return; } let giftCounter = 0; // 用于给每个礼物实例唯一 ID function triggerGiftAnimation() { giftCounter++; const giftId = `gift-${giftCounter}`; console.log(`Triggering gift animation: ${giftId}`); // 1. 创建礼物元素 const container = document.createElement('div'); container.id = giftId; container.className = 'gift-sprite-container'; // 随机或根据逻辑设置位置 container.style.bottom = `${20 + Math.random() * 100}px`; container.style.left = `${20 + Math.random() * 50}%`; const spriteElement = document.createElement('div'); spriteElement.className = 'gift-sprite-anim'; container.appendChild(spriteElement); giftArea.appendChild(container); // 2. 添加飞入效果 (如果需要) container.classList.add('fly-in'); // 应用 flyInEffect 动画 // 3. 飞入动画结束后,开始播放帧动画 // 需要监听 'animationend' 事件 container.addEventListener('animationend', (event) => { // 确保是飞入动画结束,而不是内部的 sprite 动画结束 (如果 sprite 也触发 animationend) if (event.animationName === 'flyInEffect') { console.log(`Fly-in complete for ${giftId}. Starting sprite animation.`); spriteElement.classList.add('play'); // 应用 playSprite 动画 } }, { once: true }); // 监听一次即可 // 4. 帧动画结束后,移除元素 spriteElement.addEventListener('animationend', (event) => { if (event.animationName === 'playSprite') { console.log(`Sprite animation complete for ${giftId}. Removing element.`); // 使用 setTimeout 稍微延迟移除,避免闪烁或突然消失 setTimeout(() => { if (container.parentNode === giftArea) { giftArea.removeChild(container); console.log(`Element ${giftId} removed.`); } }, 100); // 延迟 100ms } }, { once: true }); // 监听一次即可 // --- 备选方案:如果不需要飞入动画,可以直接播放 --- /* spriteElement.classList.add('play'); spriteElement.addEventListener('animationend', (event) => { if (event.animationName === 'playSprite') { console.log(`Sprite animation complete for ${giftId}. Removing element.`); setTimeout(() => { if (container.parentNode === giftArea) { giftArea.removeChild(container); console.log(`Element ${giftId} removed.`); } }, 100); } }, { once: true }); */ } triggerBtn.addEventListener('click', triggerGiftAnimation); }); </script> </body> </html>
方法五:原生 Canvas API (手动绘制/Spritesheet)
直接使用 Canvas 2D API 或 WebGL,逐帧绘制动画。对于雪碧图,就是加载图片,然后根据时间计算当前应该显示哪一帧,并使用 drawImage() 方法将雪碧图的对应部分绘制到 Canvas 上。
优点:
- 完全控制: 对渲染过程有像素级的控制。
- 无需外部库 (除了可能的图像加载): 依赖浏览器原生 API。
- 潜力性能高: 如果优化得当 (例如避免在循环中重复计算、合理使用
requestAnimationFrame),性能可以很高。WebGL 性能更佳。
缺点:
- 实现复杂: 需要手动管理动画循环、帧计算、状态、资源加载等,代码量大,容易出错。
- 性能敏感: 实现不当容易导致性能问题。
- WebGL 门槛高: WebGL 比 Canvas 2D 更强大,但也更复杂。
使用步骤与代码示例 (Canvas 2D + Spritesheet):
<!DOCTYPE html>
<html>
<head>
<title>Canvas Sprite Animation Demo</title>
<style>
body { background-color: #2c3e50; margin: 0; padding: 0; position: relative; height: 100vh; overflow: hidden; }
#live-background { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: radial-gradient(circle, #4b6cb7, #182848); display: flex; justify-content: center; align-items: center; font-size: 2em; color: white; text-shadow: 1px 1px 2px black; }
#gift-canvas-container {
position: absolute;
/* 随机放置示例 */
bottom: 10%;
left: 10%;
width: 150px; /* 画布尺寸 */
height: 150px; /* 画布尺寸 */
z-index: 10;
/* border: 1px solid red; */ /* 调试用 */
}
#gift-canvas-sprite {
display: block;
width: 100%;
height: 100%;
}
</style>
</head>
<body>
<div id="live-background">
模拟直播内容区域
</div>
<div id="gift-canvas-area">
</div>
<button id="triggerCanvasGiftBtn" style="position: absolute; top: 10px; left: 10px; z-index: 20;">Trigger Canvas Gift</button>
<script>
document.addEventListener('DOMContentLoaded', () => {
const giftArea = document.getElementById('gift-canvas-area');
const triggerBtn = document.getElementById('triggerCanvasGiftBtn');
let animationRequestId = null; // 用于存储 requestAnimationFrame ID
let activeAnimations = []; // 存储当前活动的动画实例
if (!giftArea || !triggerBtn) {
console.error('Required elements not found.');
return;
}
// --- 精灵动画类 ---
class SpriteAnimation {
constructor(options) {
this.img = null; // Image 对象
this.imgLoaded = false;
this.canvas = options.canvas;
this.ctx = this.canvas.getContext('2d');
this.spriteSheetUrl = options.spriteSheetUrl;
this.frameWidth = options.frameWidth; // 单帧宽度
this.frameHeight = options.frameHeight; // 单帧高度
this.totalFrames = options.totalFrames; // 总帧数
this.fps = options.fps || 24; // 动画帧率
this.loop = options.loop || false;
this.onComplete = options.onComplete || (() => {}); // 播放完成回调
this.currentFrame = 0;
this.lastFrameTime = 0;
this.isPlaying = false;
this.startTime = 0;
this.loadSpriteSheet();
}
loadSpriteSheet() {
this.img = new Image();
this.img.onload = () => {
console.log(`Sprite sheet loaded: ${this.spriteSheetUrl}`);
this.imgLoaded = true;
// 如果被指示立即播放
if (this.pendingPlay) {
this.play();
}
};
this.img.onerror = (err) => {
console.error(`Failed to load sprite sheet: ${this.spriteSheetUrl}`, err);
// 可能需要销毁或标记为错误状态
};
this.img.src = this.spriteSheetUrl;
}
play() {
if (!this.imgLoaded) {
console.log('Image not loaded yet, delaying play.');
this.pendingPlay = true; // 标记为等待播放
return;
}
if (this.isPlaying) return; // 防止重复启动
console.log('Playing sprite animation');
this.isPlaying = true;
this.currentFrame = 0;
this.startTime = performance.now();
this.lastFrameTime = this.startTime;
this.pendingPlay = false;
// 添加到全局动画循环中
addActiveAnimation(this);
}
stop() {
if (!this.isPlaying) return;
console.log('Stopping sprite animation');
this.isPlaying = false;
// 从全局动画循环中移除
removeActiveAnimation(this);
}
update(timestamp) {
if (!this.isPlaying || !this.imgLoaded) return;
const deltaTime = timestamp - this.lastFrameTime;
const frameDuration = 1000 / this.fps; // 每帧持续时间 (ms)
if (deltaTime >= frameDuration) {
// 计算应该前进多少帧 (处理掉帧或延迟)
const framesToAdvance = Math.floor(deltaTime / frameDuration);
this.currentFrame += framesToAdvance;
this.lastFrameTime = timestamp - (deltaTime % frameDuration); // 修正时间戳
if (this.currentFrame >= this.totalFrames) {
if (this.loop) {
this.currentFrame %= this.totalFrames; // 循环
} else {
this.currentFrame = this.totalFrames - 1; // 停在最后一帧
this.isPlaying = false; // 停止播放
console.log('Animation finished.');
removeActiveAnimation(this); // 从活动列表移除
this.onComplete(); // 调用完成回调
return; // 动画结束,不再绘制
}
}
}
}
draw() {
if (!this.imgLoaded || !this.ctx) return;
const sx = this.currentFrame * this.frameWidth; // 雪碧图中的 x 坐标
const sy = 0; // 假设雪碧图是水平排列的
const sWidth = this.frameWidth;
const sHeight = this.frameHeight;
const dx = 0; // canvas 上的 x 坐标
const dy = 0; // canvas 上的 y 坐标
const dWidth = this.canvas.width; // 绘制宽度 (等于 canvas 宽度)
const dHeight = this.canvas.height; // 绘制高度 (等于 canvas 高度)
// 清除上一帧
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// 绘制当前帧
try {
this.ctx.drawImage(
this.img,
sx, sy, sWidth, sHeight,
dx, dy, dWidth, dHeight
);
} catch (e) {
console.error("Error drawing image slice:", e, {sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight});
// 可能发生在图像加载完成但解码未完成的瞬间,或坐标计算错误
this.stop(); // 出错时停止动画
}
}
destroy() {
console.log('Destroying sprite animation instance');
this.stop();
// 清理 canvas 内容
if(this.ctx) {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
}
// 移除 DOM 元素(如果需要由这个类管理)
if (this.canvas.parentNode) {
this.canvas.parentNode.removeChild(this.canvas);
}
// 断开引用
this.img = null;
this.ctx = null;
this.canvas = null;
}
}
// --- 全局动画循环 ---
function animationLoop(timestamp) {
// 更新所有活动的动画
// 使用 [...activeAnimations] 创建副本,防止在循环中修改数组导致问题
[...activeAnimations].forEach(anim => anim.update(timestamp));
// 绘制所有活动的动画
[...activeAnimations].forEach(anim => anim.draw());
// 继续下一帧
if (activeAnimations.length > 0) {
animationRequestId = requestAnimationFrame(animationLoop);
} else {
console.log("No active animations, stopping loop.");
animationRequestId = null; // 没有动画时停止循环
}
}
// 启动全局循环 (如果需要)
function startGlobalLoop() {
if (!animationRequestId && activeAnimations.length > 0) {
console.log("Starting global animation loop.");
animationRequestId = requestAnimationFrame(animationLoop);
}
}
// 添加动画实例到活动列表
function addActiveAnimation(animInstance) {
if (!activeAnimations.includes(animInstance)) {
activeAnimations.push(animInstance);
startGlobalLoop(); // 如果循环未运行,启动它
}
}
// 从活动列表移除动画实例
function removeActiveAnimation(animInstance) {
const index = activeAnimations.indexOf(animInstance);
if (index > -1) {
activeAnimations.splice(index, 1);
console.log(`Removed animation from active list. Remaining: ${activeAnimations.length}`);
}
}
// --- 触发礼物 ---
let giftCounter = 0;
function triggerCanvasGift() {
giftCounter++;
const giftId = `canvas-gift-${giftCounter}`;
console.log(`Triggering canvas gift: ${giftId}`);
// 1. 创建容器和 Canvas
const container = document.createElement('div');
container.id = giftId + '-container';
container.className = 'gift-canvas-container';
// 设置随机位置
container.style.bottom = `${5 + Math.random() * 15}%`;
container.style.left = `${5 + Math.random() * 70}%`;
container.style.width = '120px'; // 设置 canvas 大小
container.style.height = '120px';
const canvas = document.createElement('canvas');
canvas.id = giftId;
canvas.className = 'gift-canvas-sprite';
// 设置 canvas 内部宽高,匹配 CSS 尺寸以避免模糊
canvas.width = 120;
canvas.height = 120;
container.appendChild(canvas);
giftArea.appendChild(container);
// 2. 创建并播放动画实例
const animation = new SpriteAnimation({
canvas: canvas,
spriteSheetUrl: './assets/gift_sprite_horizontal.png', // 假设是水平排列的雪碧图
frameWidth: 100, // 雪碧图中单帧的原始宽度
frameHeight: 100, // 雪碧图中单帧的原始高度
totalFrames: 10, // 雪碧图包含的总帧数
fps: 15, // 播放速度
loop: false, // 不循环
onComplete: () => {
console.log(`Animation ${giftId} completed. Removing.`);
// 动画完成后移除 Canvas 元素
setTimeout(() => { // 延迟移除
if (container.parentNode === giftArea) {
giftArea.removeChild(container);
}
// 手动清理一下 SpriteAnimation 实例内部的引用,帮助 GC
// (虽然JS会自动回收,但显式置null是好习惯)
animation.destroy(); // 调用我们添加的 destroy 方法
}, 200);
}
});
// 3. 播放动画
animation.play();
}
triggerBtn.addEventListener('click', triggerCanvasGift);
// 页面卸载时清理所有动画
window.addEventListener('beforeunload', () => {
if (animationRequestId) {
cancelAnimationFrame(animationRequestId);
animationRequestId = null;
}
// 销毁所有活动的动画实例并清理 DOM
[...activeAnimations].forEach(anim => {
// 直接调用实例的清理方法(如果实现了 destroy)
// 或者手动移除 canvas 元素
if (anim.canvas && anim.canvas.parentNode) {
anim.canvas.parentNode.removeChild(anim.canvas);
}
// 从活动列表移除
removeActiveAnimation(anim);
});
activeAnimations = []; // 清空列表
console.log("Cleaned up active canvas animations on page unload.");
});
});
</script>
</body>
</html>
对比与选择
| 特性 | yyeva (MP4+Alpha -> Canvas) | WebM (VP9+Alpha -> Video) | Lottie (JSON -> SVG/Canvas) | CSS Animation/Sprites | Canvas API (Manual Draw) |
|---|---|---|---|---|---|
| 核心技术 | 自定义视频编码 + JS解码渲染 | 标准视频格式 + <video> | AE导出JSON + JS渲染库 | CSS @keyframes, steps() | JS drawImage + rAF |
| 透明度 | 支持 (核心功能) | 支持 (VP8/VP9 Alpha) | 支持 (矢量/位图均可) | 支持 (PNG/GIF/SVG) | 支持 (Canvas本身透明) |
| 性能 | 较好 (WebGL优化) | 好 (浏览器原生优化) | 优 (矢量渲染, SVG/WebGL) | 极佳 (简单动画) | 可高可低 (依赖实现) |
| 文件大小 | 中等 (视频压缩) | 可能较大 (取决于编码) | 小 (矢量JSON为主) | 小 (CSS) / 大 (雪碧图) | 小 (代码) / 大 (雪碧图) |
| 开发复杂度 | 中 (需转换工具, JS库API) | 低 (标准HTML/JS) | 中 (需AE/Bodymovin, JS库API) | 低 (CSS) / 中 (JS控制) | 高 (手动实现循环/绘制) |
| 效果复杂度 | 高 (支持复杂视频效果) | 高 (支持复杂视频效果) | 高 (矢量), 中 (位图嵌入) | 低~中 | 高 (完全控制) |
| 依赖 | yyeva-tool, yyeva.js | FFmpeg (可选转换), 浏览器 | AE, Bodymovin, lottie-web | 无 / 雪碧图工具 | 无 / 雪碧图工具 |
| 浏览器兼容 | 良好 (依赖Canvas/WebGL) | 良好 (现代浏览器) | 良好 (现代浏览器) | 极好 | 极好 (Canvas 2D) |
| 适用场景 | 复杂动态效果, 视频源动画 | 视频源动画, 跨平台需求 | 矢量动画, UI动效, 交互动画 | 简单图标/UI动效, 帧动画 | 自定义绘制, 游戏效果 |
如何选择?
- 如果动画是矢量风格,优先考虑 Lottie。 它通常能提供最佳的质量、文件大小和性能平衡,并且有良好的跨平台支持和控制能力。
- 如果动画包含大量真实视频片段或复杂的、只能通过视频表达的效果,并且对性能要求高,
yyeva是一个专门为此优化的方案。 但需要接受其特定的工作流和依赖。 - 如果动画是视频,且希望使用更标准化的 Web 技术,可以尝试 WebM (VP9 + Alpha)。 检查目标浏览器的兼容性,并评估文件大小和编码时间。
- 对于简单的图标飞入、淡出、旋转或少量帧的动画,CSS Animations 是最轻量、性能最好的选择。
- 如果需要极高的自定义程度(例如,程序化生成动画、复杂的粒子效果与动画结合)或者希望从零开始完全掌控渲染过程,可以使用 Canvas API (2D 或 WebGL)。 但准备好投入更多的开发时间和精力进行优化。