Compartilhando a tela de uma guia do navegador em HTML5?

Nos últimos anos, ajudei algumas empresas diferentes a alcançar funcionalidades do tipo compartilhamento de tela usando apenas tecnologias de navegador. Por experiência própria, implementar VNC apenas em tecnologias de plataforma da Web (ou seja, sem plug-ins) é um problema difícil. Há muitas coisas a considerar e muitos desafios a superar. Redirecionar a posição do ponteiro do mouse, encaminhar os pressionamentos de tecla e realizar repinturas de cores completas de 24 bits a 60 fps são apenas alguns dos problemas.

Captura do conteúdo da guia

Se removermos as complexidades do compartilhamento de tela tradicional e nos concentrarmos no compartilhamento do conteúdo de uma guia do navegador, o problema será bastante simplificado para: a.) capturar a guia visível no estado atual e b.) enviar esse "quadro" pela rede. Essencialmente, precisamos de uma maneira de criar um snapshot e compartilhar o DOM.

O compartilhamento é fácil. Os Websockets são muito capazes de enviar dados em diferentes formatos (string, JSON, binário). A parte da criação de snapshots é um problema muito mais difícil. Projetos como o html2canvas enfrentaram a captura de tela em HTML implementando novamente o mecanismo de renderização do navegador... em JavaScript. Outro exemplo é o Google Feedback, embora não seja de código aberto. Esses tipos de projetos são muito legais, mas também são terrivelmente lentos. Você teria sorte de ter 1 fps, muito menos que os 60 fps.

Este artigo discute algumas das minhas soluções de prova de conceito favoritas para o compartilhamento de tela de uma guia.

Método 1: Mutation Observers + WebSocket

Uma abordagem para espelhar uma guia foi demonstrada por +Rafael Weinstein no início deste ano. A técnica dele usa Mutation Observers e um WebSocket.

Essencialmente, a guia compartilhada pelo apresentador observa as mudanças na página e envia diferenças para o visualizador usando um websocket. À medida que o usuário rola ou interage com a página, os observadores captam essas mudanças e as informam de volta usando a biblioteca de resumo de mutações de Rafael. Isso mantém o desempenho funcionando. A página inteira não é enviada para todos os frames.

Como Rafael destaca no vídeo, isso é meramente uma prova de conceito. Ainda assim, acho uma boa maneira de combinar um recurso de plataforma mais recente, como o Mutation Observers, com um mais antigo, como o Websockets.

Método 2: Blob de um HTMLDocument + WebSocket binário

Este próximo método é algo que despertou para mim. Ele é semelhante à abordagem de Mutation Observers, mas, em vez de enviar diffs de resumo, ele cria um clone Blob de todo o HTMLDocument e o envia por um websocket binário. Veja a definição por configuração:

  1. Reescreva todos os URLs da página para serem absolutos. Isso impede que imagens estáticas e recursos CSS contenham links corrompidos.
  2. Clonar o elemento do documento da página: document.documentElement.cloneNode(true);
  3. Tornar o clone somente leitura, não selecionável e impedir a rolagem usando CSS pointer-events: 'none';user-select:'none';overflow:hidden;
  4. Capture a posição de rolagem atual da página e adicione-a como atributos data-* à cópia.
  5. Crie um new Blob() com base no .outerHTML da cópia.

O código é mais ou menos assim (Fiz simplificações a partir da fonte 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() contém regex simples para reescrever URLs relativos/sem esquema para URLs absolutos. Isso é necessário para que imagens, CSS, fontes e scripts não sejam corrompidos quando visualizados no contexto de um URL de blob (por exemplo, de uma origem diferente).

Um último ajuste que fiz foi adicionar suporte à rolagem. Quando o apresentador rola a página, o espectador precisa acompanhar. Para fazer isso, guardo as posições scrollX e scrollY atuais como atributos data-* no HTMLDocument duplicado. Antes da criação do Blob final, é injetado um pouco de JS que é acionado no carregamento da 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 a rolagem dá a impressão de que fizemos uma captura de tela de uma parte da página original, quando, na verdade, duplicamos tudo e apenas o reposicionamos. #clever

Demonstração

No entanto, para o compartilhamento de guias, precisamos capturar a guia continuamente e enviá-la aos espectadores. Para isso, criei um pequeno servidor websocket, um app e um marcador de posição Node que demonstra o fluxo. Caso não tenha interesse no código, assista a este vídeo curto de como funciona:

Melhorias futuras

Uma otimização é não duplicar o documento inteiro em cada frame. Isso é um desperdício, e o exemplo do Mutation Observer faz bem. Outra melhoria é processar imagens de plano de fundo CSS relativas em urlsToAbsolute(). Isso é algo que o script atual não considera.

Método 3: API de extensão do Chrome + WebSocket binário

No Google I/O 2012, demonstrei outra abordagem para compartilhar o conteúdo de uma guia do navegador de tela. No entanto, essa é uma trapaça. Ela exige uma API de extensão do Google Chrome, não magia pura em HTML5.

A fonte deste arquivo também está no GitHub, mas a essência é:

  1. Captura a guia atual como um dataURL .png. As extensões do Chrome têm uma API para essa chrome.tabs.captureVisibleTab().
  2. Converta o dataURL em um Blob. Consulte o assistente do convertDataURIToBlob().
  3. Envie cada Blob (frame) ao visualizador usando um websocket binário definindo socket.responseType='blob'.

Exemplo

Confira este código para fazer uma captura de tela da guia atual como um png e enviar o frame por um 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);

Melhorias futuras

O frame rate é surpreendentemente bom para esse caso, mas poderia ser ainda melhor. Uma melhoria seria remover a sobrecarga de conversão do dataURL em um Blob. Infelizmente, o método chrome.tabs.captureVisibleTab() fornece apenas um dataURL. Se ele retornasse um Blob ou Typed Array, poderíamos enviá-lo diretamente pelo websocket em vez de fazer a conversão para um Blob nós mesmos. Marque crbug.com/32498 com estrela para que isso aconteça.

Método 4: WebRTC - o verdadeiro futuro

Por último, mas não menos importante.

O futuro do compartilhamento de tela no navegador será concretizado pela WebRTC. Em 14 de agosto de 2012, a equipe propôs uma API WebRTC Tab Content Capture para o compartilhamento de conteúdo de guias:

Até que esse cara esteja pronto, vamos continuar com os métodos 1 a 3.

Conclusão

Portanto, o compartilhamento de guias do navegador é possível com a tecnologia da web de hoje.

Mas... essa afirmação deve ser interpretada com muito sal. Embora sejam legais, as técnicas deste artigo não são suficientes para compartilhar a experiência do usuário de uma forma ou de outra. Tudo isso mudará com o esforço de captura de conteúdo de guias WebRTC, mas até que isso se concretize, vamos continuar com plug-ins do navegador ou soluções limitadas como as que são abordadas aqui.

Tem mais técnicas? Poste um comentário!