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

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

Captura el contenido de la pestaña

Si quitamos las complejidades del uso compartido de pantalla tradicional y nos enfocamos en compartir el contenido de una pestaña del navegador, el problema se simplifica mucho a: a.) capturar la pestaña visible en su estado actual y b.) enviar ese “marco” a través del cable. En esencia, 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 y binario). La parte de las instantáneas es un problema mucho más difícil. Proyectos como html2canvas abordaron la captura de pantalla de HTML con la reinstalación del motor de renderización del navegador… ¡en JavaScript! Otro ejemplo es Google Opiniones, aunque no es de código abierto. Estos tipos de proyectos son muy interesantes, pero también son muy lentos. Tendrías suerte de obtener una capacidad de procesamiento de 1 fps, y mucho menos de los codiciados 60 fps.

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

Método 1: Observadores de mutación + 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.

En esencia, la pestaña que comparte el presentador busca cambios en la página y envía las diferencias al usuario mediante un websocket. A medida que el usuario se desplaza o interactúa con la página, los observadores detectan estos cambios y los informan al usuario con la biblioteca de resumen de mutaciones de Rafael. Esto mantiene el rendimiento. No se envía toda la página para cada fotograma.

Como Rafael señala en el video, esto es solo una prueba de concepto. Sin embargo, creo que es una forma ingeniosa de combinar una función de plataforma más reciente, como los observadores de mutaciones, con una más antigua, como los WebSockets.

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

Este siguiente método es uno que se me ocurrió recientemente. Es similar al enfoque de los observadores de mutaciones, pero en lugar de enviar diferencias de resumen, crea un clon de Blob de todo HTMLDocument y lo envía a través de un websocket binario. A continuación, se muestra la configuración por configuración:

  1. Reescribe todas las URLs de la página para que sean absolutas. Esto evita que los recursos de imagen estática y CSS contengan vínculos rotos.
  2. Clona el elemento de documento de la página: document.documentElement.cloneNode(true);
  3. Haz que el clon sea de solo lectura, no se pueda seleccionar 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 (hice simplificaciones a partir de la fuente completa):

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 regex simples para reescribir URLs relativas o sin esquema en URLs absolutas. Esto es necesario para que las imágenes, los CSS, las fuentes y las secuencias de comandos no se rompan cuando se vean en el contexto de una URL de blob (p.ej., de un origen diferente).

Un último ajuste que hice fue agregar compatibilidad con el desplazamiento. Cuando el presentador desplace la página, el usuario debe seguirla. Para ello, oculto las posiciones actuales de scrollX y scrollY como atributos data-* en el HTMLDocument duplicado. Antes de que se cree el blob final, se inyecta un fragmento 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 lo cambiamos de posición. #clever

Demostración

Sin embargo, para compartir pestañas, debemos capturarlas de forma continua y enviarlas a los usuarios. Para ello, escribí un pequeño servidor de websocket de Node, una app y un favorito que demuestran el flujo. Si no te interesa el código, aquí tienes un breve video de cómo funciona:

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 la extensión de Chrome + WebSocket binario

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

La fuente para esta también está en GitHub, pero la idea principal es la siguiente:

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

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 caso, pero podría ser aún mejor. Una mejora sería quitar la sobrecarga de convertir el dataURL en un blob. Lamentablemente, chrome.tabs.captureVisibleTab() solo nos proporciona un dataURL. Si devolviera un objeto Blob o un array tipado, podríamos enviarlo directamente a través del websocket en lugar de hacer la conversión a un objeto Blob por nuestra cuenta. Destaca crbug.com/32498 para que eso suceda.

Método 4: WebRTC: el verdadero futuro

Por último, pero no menos importante,

WebRTC será el futuro del uso compartido de pantalla en el navegador. El 14 de agosto de 2012, el equipo propuso una API de WebRTC Tab Content Capture para compartir el contenido de las pestañas:

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

Conclusión

Por lo tanto, el uso compartido de pestañas del navegador es posible con la tecnología web actual.

Sin embargo, esa afirmación debe tomarse con cautela. Si bien son prácticas, las técnicas de este artículo no alcanzan a brindar una UX de uso compartido excelente de una forma u otra. Todo eso cambiará con el esfuerzo de captura de contenido de pestañas de WebRTC, pero hasta que eso 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.