Search code examples
iosffmpegframe-rategetusermediaweb-mediarecorder

create a timelapse video using MediaRecorder API ( and ffmpeg? )


Summary

I have a version of my code already working on Chrome and Edge (Mac Windows and Android), but I need some fixes for it to work on IOS (Safari/Chrome).
My objective is to record around 25 minutes and download a timelapse version of the recording.
final product requirements:

speed: 3fps
length: ~25s

(I need to record one frame every 20 seconds for 25 mins)

this.secondStream settings:

this.secondStream = await navigator.mediaDevices.getUserMedia({
    audio: false,
    video: {width: 430, height: 430, facingMode: "user"}
});

My code for IOS so far:

        startIOSVideoRecording: function() {
            console.log("setting up recorder");
            var self = this;
            this.data = [];

            if (MediaRecorder.isTypeSupported('video/mp4')) {
                // IOS does not support webm, so I will be using mp4
                var options = {mimeType: 'video/mp4', videoBitsPerSecond : 1000000};
            } else {
                console.log("ERROR: mp4 is not supported, trying to default to webm");
                var options = {mimeType: 'video/webm'};
            }
            console.log("options settings:");
            console.log(options);

            this.recorder = new MediaRecorder(this.secondStream, options);

            this.recorder.ondataavailable = function(evt) {
                if (evt.data && evt.data.size > 0) {
                    self.data.push(evt.data);
                    console.log('chunk size: ' + evt.data.size);
                }
            }

            this.recorder.onstop = function(evt) {
                console.log('recorder stopping');
                var blob = new Blob(self.data, {type: "video/mp4"});
                self.download(blob, "mp4");
                self.sendMail(videoBlob);
            }

            console.log("finished setup, starting")
            this.recorder.start(1200);

            function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms));}

            async function looper() {
                // I am trying to pick one second every 20 more or less
                await sleep(500);
                self.recorder.pause();
                await sleep(18000);
                self.recorder.resume();
                looper();
            }
            looper();
        },

Issues

Only one call to getUserMedia()

I am already using this.secondstream elsewhere, and I need the settings to stay as they are for the other functionality.
On Chrome and Edge, I could just call getUserMedia() again with different settings, and the issue would be solved, but on IOS calling getUserMedia() a second time kills the first stream.
The settings that I was planning to use (works for Chrome and Edge):

navigator.mediaDevices.getUserMedia({
    audio: false,
    video: { 
        width: 360, height: 240, facingMode: "user", 
        frameRate: { min:0, ideal: 0.05, max:0.1 } 
    },
}

The timelapse library I am using does not support mp4 (ffmpeg as alternative?)

I am forced to use mp4 on IOS apparently, but this does not allow me to use the library I was relying on so I need an alternative.
I am thinking of using ffmpeg but cannot find any documentation to make it interact with the blob before the download.
I do not want to edit the video after downloading it, but I want to be able to download the already edited version, so no terminal commands.

MediaRecorder pause and resume are not ideal

On Chrome and Edge I would keep one frame every 20 seconds by setting the frameRate to 0.05, but this does not seem to work on IOS for two reasons.
First one is related to the first issue of not being able to change the settings of getUserMedia() without destroying the initial stream in the first place.
And even after changing the settings, It seems that setting the frame rate below 1 is not supported on IOS. Maybe I wrote something else wrong, but I was not able to open the downloaded file.
Therefore I tried relying on pausing and resuming the MediaRecorder, but this brings forth another two issues:
I am currently saving 1 second every 20 seconds and not 1 frame every 20 seconds, and I cannot find any workarounds.
Pause and Resume take a little bit of time, making the code unreliable, as I sometimes pick 2/20 seconds instead of 1/20, and I have no reliability that the loop is actually running every 20 seconds (might be 18 might be 25).

My working code for other platforms

This is my code for the other platforms, hope it helps!
Quick note: you will need to give it a bit of time between setup and start.
The timelapse library is here


        setupVideoRecording: function() {
            let video  = { 
                width: 360, height: 240, facingMode: "user", 
                frameRate: { min:0, ideal: 0.05, max:0.1 } 
            };
            navigator.mediaDevices.getUserMedia({
                audio: false,
                video: video,
            }).then((stream) => {
                // this is a video element
                const recVideo = document.getElementById('self-recorder');
                recVideo.muted = true;
                recVideo.autoplay = true;
                recVideo.srcObject = stream;
                recVideo.play();
            });
        },

        startVideoRecording: function() {
            console.log("setting up recorder");
            var self = this;
            this.data = [];

            var video = document.getElementById('self-recorder');

            if (MediaRecorder.isTypeSupported('video/webm; codecs=vp9')) {
                var options = {mimeType: 'video/webm; codecs=vp9'};
            } else  if (MediaRecorder.isTypeSupported('video/webm')) {
                var options = {mimeType: 'video/webm'};
            }
            console.log("options settings:");
            console.log(options);

            this.recorder = new MediaRecorder(video.captureStream(), options);

            this.recorder.ondataavailable = function(evt) {
                self.data.push(evt.data);
                console.log('chunk size: ' + evt.data.size);
            }

            this.recorder.onstop = function(evt) {
                console.log('recorder stopping');
                timelapse(self.data, 3, function(blob) {
                    self.download(blob, "webm");
                });
            }

            console.log("finished setup, starting");
            this.recorder.start(40000);
        }

Solution

  • I found the solution!

    First half

    1) Only one call to getUserMedia()
    3) MediaRecorder pause and resume are not ideal
    

    So the main Problem these two points are causing is that I am unable to change the frame rate for the recording. This was somehow manageable with the use of a <canvas>.

    // the rest of the code
    
    // self-ios-recorder is a canvas element!
    // here I can set the frame rate!
    let stream = document.getElementById('self-ios-recorder').captureStream(0.05);
    this.recorder = new MediaRecorder(stream, options);
    
    // the rest of the code
    
    this.recorder.start(40000);
    this.recorder.pause();
    
    ctxrec = document.getElementById('self-ios-recorder').getContext('2d');
    async function draw_2DRecorder(){
        ctxrec.clearRect(0,0, 400, 400);
        ctxrec.drawImage(self.video, 0, 0, 400, 400);
        requestAnimationFrame(draw_2DRecorder);
    }
    draw_2DRecorder();
    

    This allows me to change the frame rate to 0.05 fps as intended, and also change the size by manipulating the canvas (although the video might get stretched).

    Second half

    2) The timelapse library I am using does not support mp4
    

    This still leaves the biggest issue: How do we change the frame rate from 0.05 to 3 if we cannot use the timelapse library?
    Well, we will have to use the pause and resume that we had just managed to avoid.... However this time we will be merging the pause and resume together with the canvas frame function, so that it can only catch one frame.
    The overall code will be looking like this:

    // the rest of the code
    
    // give the "fake" frame rate of 3
    let stream = document.getElementById('self-ios-recorder').captureStream(3);
    this.recorder = new MediaRecorder(stream, options);
    
    // the rest of the code
    
    this.recorder.start(1000);
    this.recorder.pause();
    
    ctxrec = document.getElementById('self-ios-recorder').getContext('2d');
    async function draw_2DRecorder(){
        ctxrec.clearRect(0,0, 400, 400);
        ctxrec.drawImage(self.video, 0, 0, 400, 400);
        self.recorder.resume();
        // acts like the fps is 3
        setTimeout(function(){
            self.recorder.pause();
        }, 333);
        // actually updates the frame only every 20 seconds
        setTimeout(function(){
            requestAnimationFrame(draw_2DRecorder);
        }, 20000);
    }
    draw_2DRecorder();
    

    This is still not perfect, as my previous working solution (for Mac Windows and Android) would have a final product of perfectly 1 minute -> 1 second. This code will however output a file of about 30~35 seconds for a 25 minutes video, which can probably be adjusted by changing the length of the first timeout. But it will never be consistent.

        setTimeout(function(){
            self.recorder.pause();
        }, /* ADJUST HERE */);
    

    I came up this solution after looking at this thread