Video Textures

Streaming Video Frames into a Texture

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:

One frame of a video texture, plain and bent like an old shop monitorHTML
<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>
Browser output of Listing 3.97
Browser output of 97

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.

A live video of scrolling covers streamed into a texture every frame, shown plain and through a CRT-monitor shaderHTMLLive
<!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>