¿Compartes la pantalla de una pestaña del navegador en HTML5?

En los últimos dos años, ayudé a algunas empresas a lograr una funcionalidad similar a la de compartir pantalla usando solo tecnologías de navegador. Según mi experiencia, implementar VNC únicamente en tecnologías de plataformas web (es decir, sin complementos) es un problema difícil. Hay muchos aspectos que considerar y muchos desafíos que superar. Transmitir la posición del puntero del mouse, reenviar las pulsaciones de teclas y lograr repintados de color de 24 bits completos a 60 fps son solo algunos de los problemas.

Capturando el contenido de la pestaña

Si eliminamos las complejidades de la función de compartir pantalla tradicional y nos enfocamos en compartir el contenido de una pestaña del navegador, el problema se simplifica en gran medida a: a) capturar la pestaña visible en su estado actual y b) enviar ese "fotograma" a través de la conexión. Básicamente, necesitamos una forma de tomar una instantánea del DOM y compartirla.

La parte de compartir es fácil. Los Websockets son muy capaces de enviar datos en diferentes formatos (cadena, JSON, binario). La parte de la creación de instantáneas es un problema mucho más difícil. Proyectos como html2canvas han abordado la captura de pantalla de HTML volviendo a implementar el motor de renderización del navegador… ¡en JavaScript! Otro ejemplo es Google Feedback, aunque no es de código abierto. Este tipo de proyectos son muy interesantes, pero también son terriblemente lentos. Tendrías suerte si obtuvieras una capacidad de procesamiento de 1 fps, y mucho menos esos codiciados 60 fps.

En este artículo, se analizan algunas de mis soluciones favoritas de prueba de concepto para "compartir la pantalla" de una pestaña.

Método 1: Mutation Observers y WebSocket

+Rafael Weinstein demostró un enfoque para duplicar una pestaña a principios de este año. Su técnica usa observadores de mutaciones y un WebSocket.

Básicamente, la pestaña que comparte el presentador observa los cambios en la página y envía las diferencias al visualizador con un websocket. A medida que el usuario se desplaza por la página o interactúa con ella, los observadores detectan estos cambios y los informan al visor con la biblioteca de resumen de mutaciones de Rafael. Esto mantiene el rendimiento. No se envía la página completa para cada fotograma.

Como señala Rafael en el video, esto es solo una prueba de concepto. Aun así, creo que es una forma ingeniosa de combinar una función de plataforma más nueva, como los observadores de mutaciones, con una más antigua, como Websockets.

Método 2: Blob de un HTMLDocument + WebSocket binario

Este próximo método es uno que se me ocurrió recientemente. Es similar al enfoque de Mutation Observers, pero, en lugar de enviar diferencias de resumen, crea un clon de Blob de todo el HTMLDocument y lo envía a través de un WebSocket binario. Aquí te mostramos cómo hacerlo paso a paso:

  1. Reescribe todas las URLs de la página para que sean absolutas. Esto evita que los recursos de imágenes estáticas y CSS contengan vínculos rotos.
  2. Clona el elemento de documento de la página: document.documentElement.cloneNode(true);
  3. Haz que la clonación sea de solo lectura, no seleccionable, y evita el desplazamiento con CSS pointer-events: 'none';user-select:'none';overflow:hidden;
  4. Captura la posición de desplazamiento actual de la página y agrégala como atributos data-* en el duplicado.
  5. Crea un new Blob() a partir del .outerHTML del duplicado.

El código se ve de la siguiente manera (simplifiqué el código fuente completo):

function screenshotPage() {
    // 1. Rewrite current doc's imgs, css, and script URLs to be absolute before
    // we duplicate. This ensures no broken links when viewing the duplicate.
    urlsToAbsolute(document.images);
    urlsToAbsolute(document.querySelectorAll("link[rel='stylesheet']"));
    urlsToAbsolute(document.scripts);

    // 2. Duplicate entire document tree.
    var screenshot = document.documentElement.cloneNode(true);

    // 3. Screenshot should be readyonly, no scrolling, and no selections.
    screenshot.style.pointerEvents = 'none';
    screenshot.style.overflow = 'hidden';
    screenshot.style.userSelect = 'none'; // Note: need vendor prefixes

    // 4. … read on …

    // 5. Create a new .html file from the cloned content.
    var blob = new Blob([screenshot.outerHTML], {type: 'text/html'});

    // Open a popup to new file by creating a blob URL.
    window.open(window.URL.createObjectURL(blob));
}

urlsToAbsolute() contiene expresiones regulares simples para reescribir URLs relativas o sin esquema en URLs absolutas. Esto es necesario para que las imágenes, el CSS, las fuentes y las secuencias de comandos no se rompan cuando se vean en el contexto de una URL de BLOB (p.ej., desde un origen diferente).

Un último ajuste que hice fue agregar compatibilidad con el desplazamiento. Cuando el presentador se desplace por la página, el espectador debería seguirlo. Para ello, guardo las posiciones actuales de scrollX y scrollY como atributos data-* en el elemento HTMLDocument duplicado. Antes de que se cree el Blob final, se inserta un poco de JS que se activa cuando se carga la página:

// 4. Preserve current x,y scroll position of this page. See addOnPageLoad().
screenshot.dataset.scrollX = window.scrollX;
screenshot.dataset.scrollY = window.scrollY;

// 4.5. When screenshot loads (e.g. in blob URL), scroll it to the same location
// of this page. Do this by appending a window.onDOMContentLoaded listener
// which pulls out the screenshot (dupe's) saved scrollX/Y state on the DOM.
var script = document.createElement('script');
script.textContent = '(' + addOnPageLoad_.toString() + ')();'; // self calling.
screenshot.querySelector('body').appendChild(script);

// NOTE: Not to be invoked directly. When the screenshot loads, scroll it
// to the same x,y location of original page.
function addOnPageLoad() {
    window.addEventListener('DOMContentLoaded', function(e) {
    var scrollX = document.documentElement.dataset.scrollX || 0;
    var scrollY = document.documentElement.dataset.scrollY || 0;
    window.scrollTo(scrollX, scrollY);
    });

Simular el desplazamiento da la impresión de que tomamos una captura de pantalla de una parte de la página original, cuando, en realidad, duplicamos todo y solo cambiamos su posición. #clever

Demostración

Sin embargo, para compartir una pestaña, debemos capturarla de forma continua y enviarla a los usuarios. Para eso, escribí un pequeño servidor de WebSocket de Node, una app y un bookmarklet que demuestran el flujo. Si no te interesa el código, aquí tienes un breve video de las cosas en acción:

Mejoras futuras

Una optimización es no duplicar todo el documento en cada fotograma. Eso es un desperdicio y es algo que el ejemplo de Mutation Observer hace bien. Otra mejora es controlar las imágenes de fondo de CSS relativas en urlsToAbsolute(). Eso es algo que la secuencia de comandos actual no considera.

Método 3: API de extensión de Chrome + WebSocket binario

En Google I/O 2012, mostré otro enfoque para compartir la pantalla con el contenido de una pestaña del navegador. Sin embargo, este es un truco. Requiere una API de extensión de Chrome, no solo magia de HTML5.

El código fuente de este también está en GitHub, pero lo esencial es lo siguiente:

  1. Captura la pestaña actual como una dataURL .png. Las extensiones de Chrome tienen una API para eso chrome.tabs.captureVisibleTab().
  2. Convierte la dataURL en un Blob. Consulta el ayudante convertDataURIToBlob().
  3. Envía cada Blob (fotograma) al visualizador con un WebSocket binario configurando socket.responseType='blob'.

Ejemplo

Este es el código para tomar una captura de pantalla de la pestaña actual como un archivo PNG y enviar el fotograma a través de un WebSocket:

var IMG_MIMETYPE = 'images/jpeg'; // Update to image/webp when crbug.com/112957 is fixed.
var IMG_QUALITY = 80; // [0-100]
var SEND_INTERVAL = 250; // ms

var ws = new WebSocket('ws://…', 'dumby-protocol');
ws.binaryType = 'blob';

function captureAndSendTab() {
    var opts = {format: IMG_MIMETYPE, quality: IMG_QUALITY};
    chrome.tabs.captureVisibleTab(null, opts, function(dataUrl) {
    // captureVisibleTab returns a dataURL. Decode it -> convert to blob -> send.
    ws.send(convertDataURIToBlob(dataUrl, IMG_MIMETYPE));
    });
}

var intervalId = setInterval(function() {
    if (ws.bufferedAmount == 0) {
    captureAndSendTab();
    }
}, SEND_INTERVAL);

Mejoras futuras

La velocidad de fotogramas es sorprendentemente buena para este juego, pero podría ser aún mejor. Una mejora sería quitar la sobrecarga de convertir la dataURL en un Blob. Lamentablemente, chrome.tabs.captureVisibleTab() solo nos proporciona una dataURL. Si devolviera un Blob o un array con tipo, podríamos enviarlo directamente a través del WebSocket en lugar de realizar la conversión a un Blob nosotros mismos. Destaca crbug.com/32498 para que eso suceda.

Método 4: WebRTC, el verdadero futuro

Por último, pero no menos importante,

El futuro del uso compartido de pantalla en el navegador se realizará con WebRTC. El 14 de agosto de 2012, el equipo propuso una API de captura de contenido de pestañas de WebRTC para compartir el contenido de las pestañas:

Hasta que esté listo, nos quedan los métodos del 1 al 3.

Conclusión

Por lo tanto, compartir la pestaña del navegador es posible con la tecnología web actual.

Pero… esa afirmación debe tomarse con cautela. Si bien son ingeniosas, las técnicas de este artículo no logran una gran UX de uso compartido de una forma u otra. Todo eso cambiará con el esfuerzo de captura de contenido de pestañas de WebRTC, pero hasta que sea una realidad, nos quedamos con complementos del navegador o soluciones limitadas como las que se describen aquí.

¿Tienes más técnicas? Publica un comentario.