Why is video from canvas.captureStream empty when

2020-05-02 04:11发布

问题:

I have a perfectly working demo animation within a canvas element that I can record as a webm video file with MediaRecorder and captureStream() from the <canvas> element.

The animations from the 2d context api come out just fine in the resulting video, but when I try to use drawImage() in order to add an image to the canvas I can't seem to make it work properly. In the latter case the MediaRecorder.ondataavailable handler doesn't receive valid data and the resulting video file is a 0-byte file.

I even implemented a demo where I can toggle whether or not a drawImage() call is performed. in the code below, if drawImage = false the video is generated without a problem, but if drawImage is toggled to true, it will generate a 0-byte file instead.

In order to demonstrate, I put together this jsfiddle https://jsfiddle.net/keyboardsamurai/3tkm0dp6/16/

I am running this code on "Chrome Version 75.0.3770.100 (Official Build) (64-Bit)" on MacOS - not even sure if it is supposed to run on Firefox etc. since the MediaRecorder API throws seemingly unrelated errors on FF.

See also the full code here:

<html lang="en">

<head>
    <meta charset="UTF-8">
    <title></title>
</head>
<body>

<canvas id="drawing_canvas" width="1280" height="720"></canvas>

<script>
    const image = new Image();
    image.src = 'https://66.media.tumblr.com/84d332cafeb1052c477c979281e5713b/tumblr_owe3l0tkCj1wxdq3zo1_1280.jpg';

    window.requestAnimationFrame(animation);

    const drawImage = false;  // toggle to 'true' to make this example fail
    const canvas = document.getElementById('drawing_canvas');

    const allChunks = [];
    const recorder = initMediaRecorder(canvas);
    recorder.start();

    setTimeout(function (e) {
        console.log("Video ended");
        recorder.stop();
    }, 5000);

    function initMediaRecorder(canvasElement) {
        const stream = canvasElement.captureStream(60);
        const recorder = new MediaRecorder(stream, {mimeType: 'video/webm'});
        recorder.ondataavailable = function (e) {
            console.log("data handler called");
            if (e.data) {
                console.log("data available: " + e.data.size)
                if (e.data.size > 0) {
                    console.log("data added");
                    allChunks.push(e.data);
                }
            } else {
                console.error("Data handler received no data in event: " + JSON.stringify(e))
            }
        };

        recorder.onstop = function (e) {
            const fullBlob = new Blob(allChunks);
            const link = document.createElement('a');
            link.style.display = 'none';

            link.href = window.URL.createObjectURL(fullBlob);
            link.download = 'media.webm';

            document.body.appendChild(link);
            link.click();
            link.remove();
        };
        return recorder;
    }


    function animation() {
        const now = new Date();
        const ctx = document.getElementById('drawing_canvas').getContext('2d');

        if (drawImage) {
            ctx.drawImage(image, 0, 0);
        }

        ctx.clearRect(0, 0, 150, 150);

        ctx.strokeStyle = 'white';
        ctx.fillStyle = 'white';
        ctx.rect(0, 0, 1280, 720);
        ctx.stroke();

        ctx.save();

        ctx.translate(75, 75);
        ctx.scale(0.4, 0.4);
        ctx.rotate(-Math.PI / 2);
        ctx.strokeStyle = 'black';
        ctx.fillStyle = 'white';
        ctx.lineWidth = 8;
        ctx.lineCap = 'round';

        // Hour marks
        ctx.save();
        for (var i = 0; i < 12; i++) {
            ctx.beginPath();
            ctx.rotate(Math.PI / 6);
            ctx.moveTo(100, 0);
            ctx.lineTo(120, 0);
            ctx.stroke();
        }
        ctx.restore();

        // Minute marks
        ctx.save();
        ctx.lineWidth = 5;
        for (i = 0; i < 60; i++) {
            if (i % 5 != 0) {
                ctx.beginPath();
                ctx.moveTo(117, 0);
                ctx.lineTo(120, 0);
                ctx.stroke();
            }
            ctx.rotate(Math.PI / 30);
        }
        ctx.restore();

        const sec = now.getSeconds();
        const min = now.getMinutes();
        let hr = now.getHours();
        hr = hr >= 12 ? hr - 12 : hr;

        ctx.fillStyle = 'black';

        // write Hours
        ctx.save();
        ctx.rotate(hr * (Math.PI / 6) + (Math.PI / 360) * min + (Math.PI / 21600) * sec);
        ctx.lineWidth = 14;
        ctx.beginPath();
        ctx.moveTo(-20, 0);
        ctx.lineTo(80, 0);
        ctx.stroke();
        ctx.restore();

        // write Minutes
        ctx.save();
        ctx.rotate((Math.PI / 30) * min + (Math.PI / 1800) * sec);
        ctx.lineWidth = 10;
        ctx.beginPath();
        ctx.moveTo(-28, 0);
        ctx.lineTo(112, 0);
        ctx.stroke();
        ctx.restore();

        // Write seconds
        ctx.save();
        ctx.rotate(sec * Math.PI / 30);
        ctx.strokeStyle = '#D40000';
        ctx.fillStyle = '#D40000';
        ctx.lineWidth = 6;
        ctx.beginPath();
        ctx.moveTo(-30, 0);
        ctx.lineTo(83, 0);
        ctx.stroke();
        ctx.beginPath();
        ctx.arc(0, 0, 10, 0, Math.PI * 2, true);
        ctx.fill();
        ctx.beginPath();
        ctx.arc(95, 0, 10, 0, Math.PI * 2, true);
        ctx.stroke();
        ctx.fillStyle = 'rgba(0, 0, 0, 0)';
        ctx.arc(0, 0, 3, 0, Math.PI * 2, true);
        ctx.fill();
        ctx.restore();

        ctx.beginPath();
        ctx.lineWidth = 14;
        ctx.strokeStyle = '#325FA2';
        ctx.arc(0, 0, 142, 0, Math.PI * 2, true);
        ctx.stroke();

        ctx.restore();

        window.requestAnimationFrame(animation);
    }

</script>
</body>
</html>

Update: The above behavior is confirmed to be replicable on at least these Chrom(e/ium) versions:

Version 75.0.3770.100 (Official Build) (64-Bit) on MacOS Mojave 10.14.5

Version 77.0.3849.0 (Official Build) canary (64-Bit) on MacOS Mojave 10.14.5

Version 77.0.3770.100 (Official Build) snap (64-bit) on Ubuntu 19.04 Disco Dingo

回答1:

That happens because your image is coming from a cross-domain resource and has tainted your canvas.
Tainting a canvas from which a MediaStream is being captured will stop said MediaStream from capturing any new image.

Also, trying to capture a MediaStream from such a tainted canvas will throw a SecurityError.

const ctx = canvas.getContext('2d');
const stream = canvas.captureStream();
vid.srcObject = stream;

const img = new Image();
img.onload = e => {
  console.log('will taint the canvas')
  ctx.drawImage(img, 0, 0);
  // and if we try now to capture a new stream, we have a clear error
  const stream2 = canvas.captureStream();
}
img.src = "https://66.media.tumblr.com/84d332cafeb1052c477c979281e5713b/tumblr_owe3l0tkCj1wxdq3zo1_1280.jpg";
ctx.fillRect(0,0,20,20);
<canvas id="canvas"></canvas>
<video id="vid" controls autoplay muted></video>

To circumvent it, you need the server to send the image in a cross-origin compliant way, by setting correctly the Access-control-origin headers to accept your own domain, then requesting this image with the crossorigin attribute. The server from which you are loading this particular image does allow anyone to access their data in such a cross-origin compliant way, so we can demonstrate the front-end part:

const ctx = canvas.getContext('2d');
const stream = canvas.captureStream();
vid.srcObject = stream;

const img = new Image();
img.crossOrigin = 'anonymous'; // add this to request the image as cross-origin allowed
img.onload = e => {
  console.log('will not taint the canvas anymore')
  ctx.drawImage(img, 0, 0);
  // and if we try now to capture a new stream, we have a clear error
  const stream2 = canvas.captureStream();
}
img.src = "https://66.media.tumblr.com/84d332cafeb1052c477c979281e5713b/tumblr_owe3l0tkCj1wxdq3zo1_1280.jpg";
ctx.fillRect(0,0,20,20);
<canvas id="canvas"></canvas>
<video id="vid" controls autoplay muted></video>