En algunas aplicaciones web, siempre que se necesita usar la cámara del celular la primera opción que se nos viene a la cabeza es usarla de modo nativo, de manera que hay ciertas implicaciones (como la orientación de la imagen) que pueden entorpecernos el paso en diferentes modelos y SO. En este tutorial les vamos a mostrar una manera de usar la cámara del teléfono (u otro dispositivo de vídeo) para poder sacar fotos usando solamente un <canvas> y un <video> con un poco de javascript.  

¿Qué conocimientos previos necesitamos para este tutorial?

En este tutorial vamos a usar:

  • Vue.js
  • CSS
  • HTML
  • Javascript
  • Bootstrap

Si bien no va a ser una implementación compleja, necesitamos saber al menos lo básico de Vue para poder manejar los componentes de una manera reactiva.

Nota: El código que vamos a implementar no estará soportado por Internet Explorer, ya que usa funciones lambda

¿Qué vamos a hacer para lograr lo que nos proponemos?

La implementación que pensamos va a consistir en un <canvas> sin dibujo renderizado en el DOM donde, al hacer click en un botón, dibujaremos el stream de vídeo (proveniente de un <video> que instanciaremos por javascript y pondremos oculto en el DOM). Próximamente, para sacar la foto, simplemente tendremos que detener el stream, congelando así el frame y dejándolo dibujado en el <canvas>.

Lo que tenemos importado en nuestro proyecto:

<!-- Más adelante veremos que contienen estos archivos  -->

<!-- CSS -->

<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
<link rel="stylesheet" href="/css/style.css">

<!-- Javascript -->

<script src="https://cdn.jsdelivr.net/npm/vue@2.6.7/dist/vue.js"></script>
<script src="/js/app.js"></script>
<script src="/js/cameraUtils.js"></script>
<script src="/js/component/CapturaImagenes.js"></script>

 

Nuestro .css

Acá les dejamos el .css que usaremos en este tutorial para que todo se vea de una manera más amigable que con HTML pelado:

.pnt-dimensiones-imagen{
    width: 300px; 
    height:300px;
}

.pnt-dimensiones-canvas{
    width: 300px;
    height: 300px;
}

.pnt-preview-imagen{
    display: block;
    margin-left: auto;
    margin-right: auto;
    max-width: 100%;
    max-height:100%;
}

 

Paso 1: armar el HTML y crear el template para el componente 

Dentro del HTML, vamos a crear un template para mostrar el stream y sacar la foto, el código debería quedar más o menos así:

<template id="pnt-template-captura-imagenes">
  <div>
     <div class="row text-center mt-4">
       <span class="col-md-3"></span>
         <div class="col-md-6" v-if="imagenBase64 && imagenBase64.length > 0">
           <img class="pnt-preview-imagen pnt-dimensiones-imagen" :src="imagenBase64" alt="una imagen"/> 
         </div>
         <div class="col-md-6" v-else>
            <canvas ref="canvas" class="pnt-preview-imagen pnt-dimensiones-canvas"></canvas>
         </div>
         <span class="col-md-3"></span>
     </div>
     <div class="row">
       <div class="col-md-2"></div>
          <div class="col-md-8 text-center">
             <button v-if="estadoCamara == 'LISTO'" @click="sacarFoto" class="btn btn-primary mt-4">
                 Sacar foto
             </button>
             <button v-else @click="iniciarCamara" class="btn btn-primary mt-4">
                 Iniciar C&aacute;mara
             </button>
          </div>
       <div class="col-md-2"></div>
     </div>
 </div>
</template>

 En el siguiente paso veremos los data de Vue.js y event handlers usados en estos elementos. Por ahora observemos los elementos HTML agregados:

  • Un <canvas> donde mostraremos el stream de vídeo y obtendremos el frame que será la foto,
  • un <img> que contendrá la foto obtenida a partir del <canvas> anterior y
  • dos <button> para tomar la foto y otro para inicar el stream de vídeo

Recordemos usar el template dentro de nuestro HTML:

<div class="container-fluid" id="pnt-js-tutorial-media-stream">
    <div class="row-fluid">
       <div class="span9">
          <div class="row-fluid">
             <captura-imagenes></captura-imagenes>
          </div>
       </div>
    </div>
</div>

 Paso 2: Inicializar Vue.js y crear los componentes

 Para inicializar Vue, lo que vamos a hacer es un archivo llamado app.js, donde simplemente lo instanciaremos.

También crearemos un objeto global llamado tutorial con una propiedad llamada utils:

var tutorial = tutorial || {};
tutorial.utils = tutorial.utils || {};

document.addEventListener('DOMContentLoaded', function () {
    var app = new Vue({
        el: '#pnt-js-tutorial-media-stream'
    });
    Vue.config.devtools = true
});

Como habrán visto, indicamos a Vue que el elemento a observar será el <div> que contendrá nuestro template invocado. Ahora vamos a ver la declaración del template.

Vue recomienda crear un paquete dentro del paquete convencional de Javascript con el nombre "component", para lograr una mejor organización en nuestros archivos, dentro de este, creamos el archivo CapturaImagenes.js (la convención de Vue para los archivos JS es PascalCase):

Dentro de este archivo, declaramos el template:

Vue.component('captura-imagenes', {
    data() {
        return{
            imagenBase64: "",
            estadoCamara: "APAGADO"
        };
    },
    methods: {
        sacarFoto() {
            var canvas = this.$refs.canvas;
            this.imagenBase64 = tutorial.utils.cameraUtils.tomarFoto(canvas);
        },
        iniciarCamara() {
            var self = this;
            self.actualizarEstadoCamara("CARGANDO");
            self.limpiarImagen();
            Vue.nextTick(() => {
                tutorial.utils.cameraUtils.iniciarCamara().then(function (video) {
                    self.actualizarEstadoCamara("LISTO");
                    var canvas = self.$refs.canvas;
                    self.mostrarVideo(canvas, video);
                }).catch(function (e) {
                    console.error(e);
                    alert("Ha ocurrido un error al iniciar la cámara");
                    self.actualizarEstadoCamara("APAGADO");
                });
            });
        },
        actualizarEstadoCamara(estado) {
            this.estadoCamara = estado;
        },
        limpiarImagen() {
            this.imagenBase64 = "";
        },
        mostrarVideo(canvas, video) {
            var self = this;
            video.onloadstart = function () {
                function loop() {
                    if (self.estadoCamara === "LISTO") {
                        ctx.drawImage(video,0,0);
                        setTimeout(loop, 1000 / 30); // 30fps
                    } else {
                        ctx.clearRect(0, 0, canvas.width, canvas.height);
                    }
                }
                canvas.parentNode.appendChild(video);
                video.play();
                var ctx = canvas.getContext('2d');
                loop();
            };
        }
    },
    template: document.getElementById("pnt-template-captura-imagenes").innerHTML
});

En este archivo están todos los métodos necesarios para manipular el <canvas> en el template que creamos, sin embargo, nos queda aún un último archivo, donde iniciaremos y detendremos los streams de vídeo:

Creamos un archivo cameraUtils.js en el mismo paquete que app.js y agregamos un objeto por closure a nuestro tutorial.utils:

tutorial.utils.cameraUtils = (function () {

    var videoElement;
    var mediaDeviceConstraints = {
        video: {
            facingMode: {ideal: "environment"},
            width: {ideal: 300},
            height: {ideal: 300}
        }
    };

    function getVideoStream() {
        return new Promise(function (resolve, reject) {
            videoElement = document.createElement('video');
            videoElement.muted = true;
            videoElement.hidden = true;
            detenerStreamsDeVideo();
            navigator.mediaDevices.getUserMedia(mediaDeviceConstraints)
                    .then(function (stream) {
                        gotStream(stream);
                        if ('srcObject' in videoElement) {
                            videoElement.srcObject = stream;
                        } else if (navigator.mozGetUserMedia) {
                            videoElement.mozSrcObject = stream;
                        }
                        resolve(videoElement);
                    }).catch(function (e) {
                console.error(e);
                reject();
            });
        });
    }

    function tomarFoto(canvas) {
        videoElement.pause();
        detenerStreamsDeVideo();
        return canvas.toDataURL("image/png");
    }

    function gotStream(stream) {
        window.stream = stream;
        videoElement.srcObject = stream;
    }

    function detenerStreamsDeVideo() {
        if (window.stream) {
            window.stream.getTracks().forEach(function (track) {
                track.stop();
            });
        }
    }
    return {
        iniciarCamara: getVideoStream,
        tomarFoto: tomarFoto,
        detenerStreamsDeVideo : detenerStreamsDeVideo
    };
})();

 Con el método getVideoStream() creamos un <video> y asignamos como fuente a la cámara de nuestro teléfono, y por medio de una promise le asignamos el stream de la cámara al srcObject del vídeo. Con el método detenerStreamsDeVideo() lo que hacemos es simplemente dejar de usar la cámara del teléfono.

Por último, con nuestro metodo tomarFoto(), recibimos un <canvas>, pausamos el vídeo para dejar el frame dibujado y convertimos ese frame en un base 64 para poder asignarlo al atributo imagenBase64 del componente que creamos.

Por último, revisemos el template de nuevo para ver qué hacemos con el componente y sus datos, empecemos con el <div> que contiene nuestro <img>

<div class="col-md-6" v-if="imagenBase64 && imagenBase64.length > 0">
   <img class="pnt-preview-imagen pnt-dimensiones-imagen" :src="imagenBase64" alt="una 
        imagen"/> 
</div>

   El <div> de la imagen solo la mostrará en caso de que el atributo imagenBase64 tenga un valor; este mismo atributo, luego, será usado como el src del <img>.

Debajo de este <div>, se encuentra otro que contiene el <canvas> donde dibujaremos el stream de vídeo

<div class="col-md-6" v-else>
   <canvas ref="canvas" class="pnt-preview-imagen pnt-dimensiones-canvas"></canvas>
</div>

 El v-else nos indica que este <canvas> solo será visible si es que nuestro <img> no tiene un src asignado (o lo tiene vacío). Otra cosa a notar es el atributo ref, para luego poder ser referenciado en CapturaImagenes.js.

 Por último, están los 2 botones para iniciar la cámara y tomar una foto:

<div class="col-md-8 text-center">
   <button v-if="estadoCamara == 'LISTO'" @click="sacarFoto" class="btn btn-primary mt-4">
      Sacar foto
   </button>
   <button v-else @click="iniciarCamara" class="btn btn-primary mt-4">
      Iniciar C&aacute;mara
   </button>
</div>

 Lo que hacemos con Vue en esta parte del template es mostrar el botón que toma la foto solo si la cámara se encuentra lista, y una vez que se le hace click, se ejecuta el method sacarFoto(), definido en el .js de nuestro componente. En caso de que la cámara no esté lista, se mostrará el botón para iniciarla, el cual, al hacerle click, iniciará el stream de vídeo que empezará a ser dibujado en el <canvas>

 Paso 3: ¡Probarlo!

 Una vez que esto esté listo, podemos iniciar el .html directamente desde el Sistema Operativo haciéndole doble click (o levantándolo en un servidor). Sin embargo, ¿Cómo pruebo la cámara del teléfono desde mi computadora? Para eso, debemos tener el .html en un servidor y usar el debugger de Google Chrome para dispositivos remotos, que veremos en otro artículo.

Otras preguntas para mejorar lo que hicimos:

  • ¿Cómo sería la implementación para sacar más de una foto?
  • ¿Se puede quitar el <img> del template e insertar uno desde Javascript al sacar la foto?
  • ¿Hay alguna manera de comprimir aún más la implementación nuestro componente?
  • ¿Cómo podemos adaptar el código para que funcione en Internet Explorer?

 

Mandanos tus sugerencias

Ayudanos con ideas para los artículos de este blog a contacto@somospnt.com