Search code examples
javascripthtml5-canvashtml5-videorequestanimationframe

How to cache or pre-render canvas animation


I am in the process of doing some slight modification (color keying) to an inline html5 video using canvas. Now, this video is a loop and I'm thinking there are more optimal ways then having a looping video being evaluated and repainted every single time. What optimizations if any can I do to either a) pre-render the entire animation or b) cache the original loop so that no more processing/evaluation needs to occur. I find that I'm being totally inefficient in CPU / memory usage by constantly letting it run.

I am currently using the following code (note, im using Vue.js, so assume that all the current function and variable assignments work correctly already) :

loadVideo() {
	
	this.video = document.getElementById('globe');
	this.c1 = document.getElementById("c1");
	this.ctx1 = this.c1.getContext("2d");
	let that = this;
	
	this.video.addEventListener("play", function() {
		that.vWidth = that.video.videoWidth / 2;
		that.vHeight = that.video.videoHeight / 2;
		that.fpsInterval = 1000 / 120;
		that.then = Date.now();
		that.startTime = that.then;
		that.computeFrame();
	}, false);
	
}

computeFrame() {
	
	requestAnimationFrame(this.computeFrame);
	this.now = Date.now();
	this.elapsed = this.now - this.then;
	
	if (this.elapsed > this.fpsInterval) {
		this.ctx1.canvas.width = this.video.offsetWidth;
		this.ctx1.canvas.height = this.video.offsetHeight;
		if (this.video.offsetWidth > 0 && this.video.offsetHeight > 0) {
			this.then = this.now - (this.elapsed % this.fpsInterval);
			this.ctx1.drawImage(this.video, 0, 0, this.ctx1.canvas.width, this.ctx1.canvas.height);
			let frame = this.ctx1.getImageData(0, 0, this.ctx1.canvas.width, this.ctx1.canvas.height);
			let l = frame.data.length / 4;
			let primaryColor = this.ctx1.getImageData(0, 0, 8, 8).data;
			let primaryR = primaryColor[60];
			let primaryG = primaryColor[61];
			let primaryB = primaryColor[62];
			for (let i = 0; i < l; i++) {
				let r = frame.data[i * 4 + 0];
				let g = frame.data[i * 4 + 1];
				let b = frame.data[i * 4 + 2];
				if (r == primaryR && g == primaryG && b == primaryB) {
					frame.data[i * 4 + 1] = 255;
					frame.data[i * 4 + 2] = 0;
					frame.data[i * 4 + 3] = 0;
				}
			}
			this.ctx1.putImageData(frame, 0, 0);
		}
	}
	
}

loadVideo();


Solution

  • You can use a MediaRecorder to record the video stream returned by your cavnas captureStream() method to record the first pass, and then read the resulting recorded video directly in a looping <video>:

    btn.onclick = e => {
      // initialise the <video>
      const vid = document.createElement('video');
      vid.muted = true;
      vid.crossOrigin = true;
      vid.src = "https://upload.wikimedia.org/wikipedia/commons/transcoded/a/a4/BBH_gravitational_lensing_of_gw150914.webm/BBH_gravitational_lensing_of_gw150914.webm.480p.webm";
      vid.playbackRate = 2;
      vid.onplaying = startProcessing;
      vid.play();
      btn.remove();
      log.textContent = 'fetching';
    };
    
    function startProcessing(evt) {
      // when video is playing
      const vid = evt.target;
      const canvas = document.createElement('canvas');
      const ctx = canvas.getContext('2d');
      canvas.width = vid.videoWidth;
      canvas.height = vid.videoHeight;
      // show the canvas for first round
      document.body.appendChild(canvas);
      // force first frame
      anim();
      
      const chunks = [];   // we'll store our recorder's data here
      const canvasStream = canvas.captureStream();
      const vp = 'video/webm; codecs="vp',
        vp8 = vp + '8"',
        vp9 = vp + '9"';
      const recorder = new MediaRecorder(canvasStream, {
        mimeType: MediaRecorder.isTypeSupported(vp9) ? vp9:vp8,
        videoBitsPerSeconds: 5000000
      });
      // every time new data is available
      recorder.ondataavailable = evt => chunks.push(evt.data);
      // record until the video ends
      vid.onended = evt => {
        recorder.stop();
      };
      recorder.onstop = exportVid;
    
      recorder.start();
      log.textContent = "recording";
      
      function anim() {
        if(vid.paused) {
          console.log('stop drawing');
          return;
        }
        ctx.drawImage(vid, 0, 0);
        applyFilter(ctx);
        requestAnimationFrame(anim);
      }
      
      function exportVid() {
        // concatenate all our chunks in a single Blob
        const blob = new Blob(chunks);
        const url = URL.createObjectURL(blob);
        // we reuse the same <video> (for Safari autoplay)
        vid.onplaying = vid.onended = null;
        vid.src = url;
        vid.loop = true;
        vid.playbackRate = 1;
        log.textContent = "playing";
    
    vid.play().then(()=> canvas.replaceWith(vid));
      }
    }
    
    
    function applyFilter(ctx) {
      const img = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height);
      const data = new Uint32Array(img.data.buffer);
      for(let i=0; i<data.length; i++) {
        if(data[i] < 0xFF111111) data[i] = ((Math.random()*0xFFFFFF) + 0xFF000000 | 0);
      }
      ctx.putImageData(img, 0, 0);
    }
    canvas,video{
      max-height: 100vh;
      max-width: 100vw;
    }
    <button id="btn">start</button><pre id="log"></pre>