与Adrian一起探索YouTube发光效果背后的秘密,以及如何将其应用于己的视频,使视频更具沉浸感。探索YouTube的“环境模式”功能,了解如何使用HTML的<canvas>
元素和requestAnimationFrame函数来创建这种发光效果。
我前一段时间在使用YouTube的深色主题时注意到了一个有意思的效果。视频播放器周围的背景会随着视频播放而变化,为视频播放器营造出一种发光的效果,使原本平淡无奇的背景变得更加有趣。
注意视频播放器周围的发光效果。CSS 布局已在浏览器中进行了编辑,以使效果更加明显,这就是为什么视频播放器看起来有点不一样。(大预览)
这种效果被称为环境模式。这个功能在2022年的某个时候发布,YouTube对其的描述如下:
“环境模式使用照明效果,通过将视频中的温和色彩投射到屏幕背景上,使在深色主题下观看视频更具沉浸感。”
— YouTube
这是一个非常微妙的效果,尤其是当视频的颜色较暗,与深色主题的背景相比,对比度较低时。
背景从顶部到底部变化,与当前帧相匹配。CSS 布局已在浏览器中进行了编辑,以使效果更加明显,这就是为什么视频播放器看起来有所不同。(大预览)
好奇心袭来,我开始尝试自己复刻这个效果。在浏览 YouTube 的复杂 DOM 树和 DevTools 中的源代码后,我遇到了一个障碍:所有的神奇效果都隐藏在 HTML 的 <canvas>
元素和大量混淆和压缩的 JavaScript 代码背后。
即使源代码没有完全给我答案,研究 HTML 画布可能是一个很好的起点。(大预览)
尽管可用的信息很少,我还是决定对代码进行逆向工程,并分享我创建视频周围环境光晕的过程。我更喜欢保持简单和易于理解,所以本文不会涉及复杂的颜色采样算法,尽管我们将通过不同的方法利用它们。
在我们开始编写代码之前,我认为重新审视一下 HTML 画布元素,看看为什么以及如何将其用于这个小效果是一个好主意。
HTML Canvas
HTML <canvas>
元素是一个容器元素,我们可以使用其自身的 Canvas API 和 WebGL API 通过 JavaScript 绘制图形。开箱即用,<canvas>
是空的 — 可以将其看作是一个空白画布 — 前面提到的 Canvas 和 WebGL API 用于将内容填充到 <canvas>
中。
HTML <canvas>
不仅限于展示,我们还可以使用它们创建响应标准鼠标和键盘事件的交互式图形。
但是 SVG 也能完成大部分这些事情,但是 <canvas>
比 SVG 更高效,因为它不需要为绘制路径和形状而创建额外的 DOM 节点,就像 SVG 那样。此外,<canvas>
容易更新,这使其非常适合更复杂和性能需求较高的用例,比如 YouTube 的环境模式。
正如你可能对许多 HTML 元素有所期望,<canvas>
接受属性。例如,我们可以为绘图区域指定宽度和高度:
<canvas width="10" height="6" id="js-canvas"></canvas>
<canvas>
不是自闭合标签,就像 <iframe>
或 <img>
。我们可以在开标签和闭标签之间添加内容,只有在浏览器无法渲染画布时,该内容才会被渲染出来。这也可以用于增加元素的可访问性,我们稍后会提到。
回到宽度和高度属性,它们定义了 <canvas>
的坐标系统。有趣的是,我们可以使用 CSS 中的相对单位来应用自适应宽度,但 <canvas>
仍然尊重设置的坐标系统。我们在这里使用的是像素图形,因此将较小的画布拉伸到较宽的容器中会导致图像模糊和像素化。
一个使用 10×6 像素坐标系统的自适应画布元素,被拉伸到 1280 像素宽度,导致图像出现像素化效果。 (图片来源:Big Buck Bunny 视频)(大预览)
<canvas>
的缺点是它的可访问性。所有的内容更新都是在 JavaScript 中后台进行的,因为 DOM 不会被更新,所以我们需要努力使其具有可访问性。其中一种方法(不止一种)是通过在 <canvas>
内部放置标准的 HTML 元素,然后手动更新它们以反映当前在画布上显示的内容,从而创建一个回退的 DOM。
添加到画布的任何内容都不会反映在 DOM 中,包括简单的文本。所有的东西都隐藏在画布后面。(大预览)
众多的画布框架,包括 ZIM、Konva 和 Fabric 等,都专为复杂用例设计,可以通过大量的抽象和工具简化过程。ZIM 框架在其交互组件中内置了可访问性功能,这使得开发可访问的基于 <canvas>
的体验稍微变得更容易。
在这个示例中,我们将使用 Canvas API。我们还将使用 <canvas>
元素进行装饰性的目的(即,它不会引入任何新内容),因此我们不必担心使其具有可访问性,而是安全地将 <canvas>
隐藏在辅助设备中。
尽管如此,我们仍然需要为那些在系统或浏览器级别启用了减少动效设置的人禁用或最小化这种效果。
requestAnimationFrame
<canvas>
元素可以处理渲染部分的问题,但我们需要以某种方式将 <canvas>
与正在播放的 <video>
保持同步,并确保每帧视频时 <canvas>
更新。我们还需要在视频暂停或结束时停止同步。
我们可以在 JavaScript 中使用 setInterval,并将其设置为以 60fps 运行,以匹配视频的播放速率,但这种方法会带来一些问题和注意事项。幸运的是,有一种更好的处理需要经常调用的函数的方式。
这就是 requestAnimationFrame 。它是浏览器在下次重绘之前运行一个函数。该函数异步运行并返回一个表示请求 ID 的数字。然后,我们可以使用这个 ID 与 cancelAnimationFrame 函数一起,以指示浏览器停止运行先前计划的函数。
let requestId;
const loopStart = () => {
/* ... */
/* Initialize the infinite loop and keep track of the requestId */
requestId = window.requestAnimationFrame(loopStart);
};
const loopCancel = () => {
window.cancelAnimationFrame(requestId);
requestId = undefined;
};
现在,通过学习如何保持更新循环和渲染的高性能,我们可以开始着手制作环境模式效果了!
方法概述
让我们简要概述一下我们创建这个效果将采取的步骤。
首先,我们必须在画布上渲染显示的视频帧,并保持一切同步。我们将帧渲染到一个较小的画布上(导致图像像素化)。当图像被降低分辨率时,图像的重要和最显著的部分会被保留,代价是丢失了一些小细节。通过将图像降低到低分辨率,我们将其减少到最显著的颜色和细节,实际上类似于颜色采样,尽管不如精确。
比较原始视频与降低分辨率的画布。(大预览)
接下来,我们将对画布进行模糊处理,将像素化的颜色进行混合。我们将使用CSS的绝对定位将画布置于视频后方。
展示在画布元素中的模糊效果。(大预览)
最后,我们将应用额外的 CSS,使发光效果变得更加微妙,尽量接近 YouTube 的效果。
带有额外样式的模糊效果。(大预览)
HTML 标记
首先,让我们从设置标记开始。我们需要将 <video>
和 <canvas>
元素包装在一个父容器中,因为这可以使我们包含后面将要使用的用于将 <canvas>
定位在 <video>
后面的绝对定位。稍后再详细解释这一点。
接下来,我们将在 <canvas>
上设置固定的宽度和高度,尽管该元素仍将保持响应式。通过设置宽度和高度属性,我们在 CSS 像素中定义了坐标空间。视频的帧大小是 1920×720 像素,所以我们将在画布上绘制一个 10×6 像素的图像。正如我们在之前的示例中看到的那样,我们将得到一个带有部分保留的主要颜色的像素化图像。
<section class="wrapper">
<video controls muted class="video" id="js-video" src="video.mp4"></video>
<canvas width="10" height="6" aria-hidden="true" class="canvas" id="js-canvas"></canvas>
</section>
同步 <canvas>
和 <video>
首先,让我们设置好我们的变量。我们需要 <canvas>
的渲染上下文来进行绘制,因此将其保存为一个变量,我们可以通过使用 JavaScript 的 getCanvasContext
函数来获取上下文。我们还将 requestAnimationFrame
方法的请求 ID赋值给step
。
const video = document.getElementById("js-video");
const canvas = document.getElementById("js-canvas");
const ctx = canvas.getContext("2d");
let step; // Keep track of requestAnimationFrame id
接下来,我们将创建绘制和更新循环函数。实际上,我们可以通过将 <video>
元素传递给 drawImage
函数,在 <canvas>
上绘制当前视频帧。drawImage
函数需要四个值,对应于视频在 <canvas>
坐标系统中的起始和结束点,如果你还记得的话,这个坐标系统与标记中的宽度和高度属性相对应。就是这么简单!
const draw = () => {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
};
现在,我们所需要做的就是创建一个循环,当视频正在播放时调用 drawImage
函数,还需要一个取消循环的函数。
const drawLoop = () => {
draw();
step = window.requestAnimationFrame(drawLoop);
};
const drawPause = () => {
window.cancelAnimationFrame(step);
step = undefined;
};
最后,我们需要创建两个主要函数,在页面加载和卸载时分别设置和清除事件监听器。以下是我们需要涵盖的所有视频事件:
loadeddata
:在视频的第一帧加载时触发。在这种情况下,我们只需将当前帧绘制到画布上。seeked
:在视频完成搜索并准备播放(即帧已更新)时触发。在这种情况下,我们只需将当前帧绘制到画布上。play
:在视频开始播放时触发。我们需要为此事件启动循环。pause
:在视频暂停时触发。我们需要为此事件停止循环。en``de``d
:在视频在达到结尾时停止播放时触发。我们需要为此事件停止循环。
const init = () => {
video.addEventListener("loadeddata", draw, false);
video.addEventListener("seeked", draw, false);
video.addEventListener("play", drawLoop, false);
video.addEventListener("pause", drawPause, false);
video.addEventListener("ended", drawPause, false);
};
const cleanup = () => {
video.removeEventListener("loadeddata", draw);
video.removeEventListener("seeked", draw);
video.removeEventListener("play", drawLoop);
video.removeEventListener("pause", drawPause);
video.removeEventListener("ended", drawPause);
};
window.addEventListener("load", init);
window.addEventListener("unload", cleanup);
让我们来看看我们通过配置的变量、函数和事件监听器所实现的效果。
https://code.juejin.cn/pen/7267465292509839375
查看 Video + Canvas 设置 - 主要颜色 [分支]。
这就是较为困难的部分!我们已经成功设置了这一点,使得 <canvas>
在与 <video>
播放同步更新。请注意流畅的性能!
模糊和样式化
我们可以在获取 <canvas>
元素的渲染上下文后,直接对整个画布应用 blur()
滤镜。或者,我们也可以直接在 CSS 中对 <canvas>
元素应用模糊效果,但我想展示一下使用 Canvas API 实现这一点是相对容易的。
const video = document.getElementById("js-video");
const canvas = document.getElementById("js-canvas");
const ctx = canvas.getContext("2d");
let step;
/* Blur filter */
ctx.filter = "blur(1px)";
/* ... */
现在只剩下一件事要做,那就是添加将 <canvas>
放置在 <video>
后面的 CSS。此外,在此过程中,我们还将为 <canvas>
应用透明度,使发光效果更加微妙,还会为包装元素添加内部阴影以柔化边缘。我在 CSS 中通过它们的类名选择这些元素。
:root {
--color-background: rgb(15, 15, 15);
}
* {
box-sizing: border-box;
}
.wrapper {
position: relative; /* Contains the absolute positioning */
box-shadow: inset 0 0 4rem 4.5rem var(--color-background);
}
.video,
.canvas {
display: block;
width: 100%;
height: auto;
margin: 0;
}
.canvas {
position: absolute;
top: 0;
left: 0;
z-index: -1; /* Place the canvas in a lower stacking level */
width: 100%;
height: 100%;
opacity: 0.4; /* Subtle transparency */
}
.video {
padding: 7rem; /* Spacing to reveal the glow */
}
我们成功地实现了这个效果,看起来与 YouTube 的实现非常接近。YouTube 的团队可能采用了完全不同的方法,也许是使用了自定义的颜色采样算法或者添加了微妙的过渡效果。无论如何,这是一个很棒的结果,无论如何都可以进一步进行扩展。
模糊的背景画布与当前的视频帧相匹配。(大预览)
创建可复用的类
通过将代码转换为 ES6 类,我们可以使其可复用,从而可以为任何 <video>
和 <canvas>
组合创建一个新的实例。
class VideoWithBackground {
video;
canvas;
step;
ctx;
constructor(videoId, canvasId) {
this.video = document.getElementById(videoId);
this.canvas = document.getElementById(canvasId);
window.addEventListener("load", this.init, false);
window.addEventListener("unload", this.cleanup, false);
}
draw = () => {
this.ctx.drawImage(this.video, 0, 0, this.canvas.width, this.canvas.height);
};
drawLoop = () => {
this.draw();
this.step = window.requestAnimationFrame(this.drawLoop);
};
drawPause = () => {
window.cancelAnimationFrame(this.step);
this.step = undefined;
};
init = () => {
this.ctx = this.canvas.getContext("2d");
this.ctx.filter = "blur(1px)";
this.video.addEventListener("loadeddata", this.draw, false);
this.video.addEventListener("seeked", this.draw, false);
this.video.addEventListener("play", this.drawLoop, false);
this.video.addEventListener("pause", this.drawPause, false);
this.video.addEventListener("ended", this.drawPause, false);
};
cleanup = () => {
this.video.removeEventListener("loadeddata", this.draw);
this.video.removeEventListener("seeked", this.draw);
this.video.removeEventListener("play", this.drawLoop);
this.video.removeEventListener("pause", this.drawPause);
this.video.removeEventListener("ended", this.drawPause);
};
}
现在,我们可以通过将 <video>
和 <canvas>
元素的 id 值传递给 VideoWithBackground()
类来创建一个新的实例:
const el = new VideoWithBackground("js-video", "js-canvas");
尊重用户偏好
前面,我们简要讨论了对于偏好减少动效的用户,我们需要禁用或最小化效果的动画。对于像这样的装饰性效果,我们必须考虑到这一点。
简单的方法是什么?我们可以使用 prefers-reduced-motion
媒体查询来检测用户的动效偏好,并在偏好为减少动效时完全隐藏装饰性的 <canvas>
。
@media (prefers-reduced-motion: reduce) {
.canvas {
display: none !important;
}
}
另一种尊重减少动效偏好的方式是使用 JavaScript 的 matchMedia
函数来检测用户的偏好,并防止必要的事件监听器进行注册。
constructor(videoId, canvasId) {
const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
if (!mediaQuery.matches) {
this.video = document.getElementById(videoId);
this.canvas = document.getElementById(canvasId);
window.addEventListener("load", this.init, false);
window.addEventListener("unload", this.cleanup, false);
}
}
最终演示
我们创建了一个可重用的 ES6 类,可以用来创建新的实例。请随意查看并尝试查看完成的演示。
https://code.juejin.cn/pen/7267466659105865782
查看 Youtube 视频发光效果 - 主要颜色 [分支]。
创建一个 React 组件
让我们将这段代码迁移到 React 库中,因为在实现上存在关键差异,如果您计划在 React 项目中使用这个效果,了解这些差异是很有价值的。
创建一个自定义 Hook
让我们首先创建一个自定义的 React Hook。不再使用 getElementById
函数来选择 DOM 元素,而是通过 useRef
钩子在 DOM 元素上创建一个 ref,并将其分配给 <canvas>
和 <video>
元素。
我们还会使用 useEffect
钩子来初始化和清除事件监听器,以确保它们只在所有必要的元素都挂载后运行一次。
我们的自定义 Hook 必须分别返回我们需要附加到 <canvas>
和 <video>
元素的 ref 值。
import { useRef, useEffect } from "react";
export const useVideoBackground = () => {
const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
const canvasRef = useRef();
const videoRef = useRef();
const init = () => {
const video = videoRef.current;
const canvas = canvasRef.current;
let step;
if (mediaQuery.matches) {
return;
}
const ctx = canvas.getContext("2d");
ctx.filter = "blur(1px)";
const draw = () => {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
};
const drawLoop = () => {
draw();
step = window.requestAnimationFrame(drawLoop);
};
const drawPause = () => {
window.cancelAnimationFrame(step);
step = undefined;
};
// Initialize
video.addEventListener("loadeddata", draw, false);
video.addEventListener("seeked", draw, false);
video.addEventListener("play", drawLoop, false);
video.addEventListener("pause", drawPause, false);
video.addEventListener("ended", drawPause, false);
// Run cleanup on unmount event
return () => {
video.removeEventListener("loadeddata", draw);
video.removeEventListener("seeked", draw);
video.removeEventListener("play", drawLoop);
video.removeEventListener("pause", drawPause);
video.removeEventListener("ended", drawPause);
};
};
useEffect(init, []);
return {
canvasRef,
videoRef,
};
};
定义组件
我们将使用类似的标记来创建实际的组件,然后调用我们的自定义 Hook,并将 ref 值分别附加到它们的元素上。我们将使组件可配置,这样我们就可以将任何 <video>
元素的属性,例如 src
,作为属性传递进来。
import React from "react";
import { useVideoBackground } from "../hooks/useVideoBackground";
import "./VideoWithBackground.css";
export const VideoWithBackground = (props) => {
const { videoRef, canvasRef } = useVideoBackground();
return (
<section className="wrapper">
<video ref={ videoRef } controls className="video" { ...props } />
<canvas width="10" height="6" aria-hidden="true" className="canvas" ref={ canvasRef } />
</section>
);
};
现在只需调用组件并将视频的 URL 作为属性传递给它即可。
import { VideoWithBackground } from "../components/VideoWithBackground";
function App() {
return (
<VideoWithBackground src="http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4" />
);
}
export default App;
总结
我们将 HTML <canvas>
元素和对应的 Canvas API 与 JavaScript 的 requestAnimationFrame
方法结合起来,创建了与 YouTube 的环境模式特性相同的性能密集的视觉效果。我们找到了一种方法,可以在 <canvas>
上绘制当前的 <video>
帧,保持两个元素同步,并将它们定位,使得模糊的 <canvas>
正确地位于 <video>
后面。
在这个过程中,我们还涵盖了一些其他的考虑因素。例如,我们将 <canvas>
定义为一种装饰性图像,当用户的系统设置为减少动效偏好时,可以将其删除或隐藏。此外,我们考虑了我们的工作的可维护性,将其作为一个可重用的 ES6 类,可以用于在页面上添加更多实例。最后,我们将效果转换为一个可以在 React 项目中使用的组件。
请查看完成的演示。鼓励大家在此基础上继续构建,并在评论中与我分享你的成果,或者你也可以在 Twitter 上联系我。我很愿意听听你的想法,并看看你能从中创造出什么!
参考资料
-
“ 图形画布元素”(MDN)
-
“CanvasRenderingContext2D”(MDN)
-
“为我们的近距离看看准备好:YouTube 的更新外观和感觉”,Nate Koechley(YouTube 博客)