texImage2D() also accepts a <video> element and copies its current frame. requestVideoFrameCallback() (Baseline since October 2024) calls you as each frame is ready, with its media time. The demo plays a 4-second, 320 x 180 WebM clip of the covers scrolling past (made in Chrome 1 with MediaRecorder), uploads every frame, and at the first frame past 1.5 s pauses and draws it plain and through a CRT-monitor shader:
<canvas id="c" width="1240" height="350" style="width:620px;height:175px"></canvas>
<video id="v" src="booknest-promo.webm" muted hidden></video>
<script src="webgl-helper.js"></script>
<script type="module">
const video = document.getElementById('v'), canvas = document.getElementById('c');
const gl = canvas.getContext('webgl2');
const head = `#version 300 es
precision mediump float; uniform sampler2D uVideo; in vec2 vUV; out vec4 c;`;
const plain = head + 'void main() { c = texture(uVideo, vUV); }';
const monitor = head + `void main() {
vec2 p = vUV * 2.0 - 1.0, uv = p * (1.0 + 0.08 * dot(p, p)) * 0.5 + 0.5; // bulge
float inside = step(abs(uv.x - 0.5), 0.5) * step(abs(uv.y - 0.5), 0.5);
float scan = 0.85 + 0.15 * sin(uv.y * 540.0); // scanlines
c = vec4(texture(uVideo, uv).rgb * scan * inside, 1.0);
}`;
let texture = null;
await video.play(); // muted, so autoplay is allowed
video.requestVideoFrameCallback(function frame(now, { mediaTime, presentedFrames }) {
if (!texture) texture = GLH.texture(gl, video, { mipmaps: false }); // first frame
else gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, video);
if (mediaTime < 1.5) return video.requestVideoFrameCallback(frame);
video.pause();
gl.viewport(0, 0, 620, 350); GLH.paint(canvas, plain);
gl.viewport(620, 0, 620, 350); GLH.paint(canvas, monitor);
console.log(`${video.videoWidth} x ${video.videoHeight}, frame ${presentedFrames}` +
` at ${mediaTime.toFixed(3)} s`);
window.__done = true;
});
</script>
The figure is one captured frame of the running clip: this run logged 320 x 180, frame 38 at 1.534 s, and the frame number varies slightly between runs. A player would draw on every callback, with programs compiled once. Create the texture on the first callback, not right after play(): before a frame is decoded the upload is empty, and every later texSubImage2D() fails.
<!doctype html>
<style>
body { margin: 0; font: 11px system-ui, sans-serif; background: #f7f4ee; color: #333; }
canvas#c { display: block; width: 100%; max-width: 600px; }
p { max-width: 600px; margin: 4px 0; font-family: monospace; }
</style>
<canvas id="c" width="1240" height="350"></canvas>
<video id="v" muted playsinline hidden></video>
<p id="info">waiting for the first video frame...</p>
<script type="module">
// The video source: a 2D canvas animated here and captured as a MediaStream, so no file is loaded.
const promo = Object.assign(document.createElement('canvas'), { width: 320, height: 180 });
const g = promo.getContext('2d');
const covers = [['#1f5f8b', 'The Quiet Harbor'], ['#5b3f99', "Clockmaker's"], ['#e09a10', 'Salt and Saffron'], ['#3f7d3a', 'Small Steps']];
function drawPromo(t) {
g.fillStyle = '#20242a'; g.fillRect(0, 0, 320, 180);
covers.forEach(([color, title], i) => {
const x = ((i * 110 - t * 60) % 440 + 440) % 440 - 100;
g.fillStyle = color; g.fillRect(x, 30, 90, 130);
g.fillStyle = '#fff'; g.font = 'bold 11px sans-serif'; g.fillText(title, x + 6, 60);
});
g.fillStyle = '#e09a10'; g.font = 'bold 16px sans-serif'; g.fillText('BookNest: new this week', 10, 20);
}
(function animate(ms) { drawPromo(ms / 1000); requestAnimationFrame(animate); })(0);
const video = document.getElementById('v');
video.srcObject = promo.captureStream(30);
const canvas = document.getElementById('c');
const gl = canvas.getContext('webgl2');
const vs = `#version 300 es
void main() { vec2 p = vec2(gl_VertexID & 1, gl_VertexID >> 1); gl_Position = vec4(p * 4.0 - 1.0, 0.0, 1.0); }`;
const head = `#version 300 es
precision mediump float; uniform sampler2D uVideo; uniform vec2 uOrigin; out vec4 c;
vec2 uvOf() { vec2 uv = (gl_FragCoord.xy - uOrigin) / vec2(620.0, 350.0); return vec2(uv.x, 1.0 - uv.y); }`;
const plain = head + 'void main() { c = texture(uVideo, uvOf()); }';
const monitor = head + `void main() {
vec2 p = uvOf() * 2.0 - 1.0, uv = p * (1.0 + 0.08 * dot(p, p)) * 0.5 + 0.5; // bulge
float inside = step(abs(uv.x - 0.5), 0.5) * step(abs(uv.y - 0.5), 0.5);
float scan = 0.85 + 0.15 * sin(uv.y * 540.0); // scanlines
c = vec4(texture(uVideo, uv).rgb * scan * inside, 1.0);
}`;
function program(fs) {
const p = gl.createProgram();
for (const [type, src] of [[gl.VERTEX_SHADER, vs], [gl.FRAGMENT_SHADER, fs]]) {
const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
gl.attachShader(p, s);
}
gl.linkProgram(p);
return p;
}
const programs = [program(plain), program(monitor)]; // compiled once
gl.bindVertexArray(gl.createVertexArray());
gl.clearColor(0.13, 0.14, 0.16, 1); gl.clear(gl.COLOR_BUFFER_BIT);
let texture = null;
await video.play(); // muted, so autoplay is allowed
video.requestVideoFrameCallback(function frame(now, { mediaTime, presentedFrames }) {
if (!texture) { // create it on the first decoded frame
texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, video);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); // no mipmaps for per-frame data
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
} else gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, video); // same size: replace
programs.forEach((p, i) => {
gl.useProgram(p);
gl.viewport(i * 620, 0, 620, 350);
gl.uniform2f(gl.getUniformLocation(p, 'uOrigin'), i * 620, 0);
gl.drawArrays(gl.TRIANGLES, 0, 3);
});
document.getElementById('info').textContent =
`${video.videoWidth} x ${video.videoHeight}, frame ${presentedFrames} at ${mediaTime.toFixed(2)} s`;
video.requestVideoFrameCallback(frame); // ask for the next frame
});
</script>