Partager l'écran d'un onglet de navigateur au format HTML5 ?

Au cours des deux dernières années, j'ai aidé quelques entreprises à mettre en place des fonctionnalités de partage d'écran en n'utilisant que les technologies des navigateurs. D'après mon expérience, l'implémentation de VNC uniquement dans les technologies de plate-forme Web (c'est-à-dire sans plug-in) est un problème difficile. Il y a beaucoup de choses à prendre en compte et beaucoup de défis à surmonter. Le relais de la position du pointeur de la souris, le transfert des combinaisons de touches et la réalisation d'un repeinture 24 bits complète à 60 FPS ne sont que quelques exemples.

Capture du contenu de l'onglet...

Si nous éliminons les complexités du partage d'écran traditionnel et nous concentrons sur le partage du contenu d'un onglet de navigateur, le problème se simplifie grandement pour a.) capturer l'onglet visible dans son état actuel et b.) envoyer ce "frame" sur le réseau. Nous avons besoin d'un moyen de prendre un instantané du DOM et de le partager.

Le partage est facile. Les Websockets sont très capables d'envoyer des données dans différents formats (chaîne, JSON, binaire). La création d'instantanés est un problème beaucoup plus difficile. Des projets tels que html2canvas ont abordé la capture d'écran HTML en réimplémentant le moteur de rendu du navigateur en JavaScript. Google Feedback est un autre exemple, bien qu'il ne s'agisse pas du code Open Source. Ces types de projets sont très cool, mais ils sont aussi terriblement lents. Vous aurez la chance d'obtenir un débit de 1 FPS, bien moins que ce que vous attendiez de 60 FPS.

Cet article présente quelques-unes de mes solutions de démonstration de faisabilité préférées pour le "partage d'écran" d'un onglet.

Méthode 1: Observateurs de mutation + WebSocket

Rafael Weinstein a présenté une approche de mise en miroir d'un onglet plus tôt cette année. Sa technique utilise Mutation Observers et un WebSocket.

Pour l'essentiel, l'onglet partagé par le présentateur surveille les modifications apportées à la page et envoie des diff au lecteur à l'aide d'un websocket. Lorsque l'utilisateur fait défiler la page ou interagit avec celle-ci, les observateurs détectent ces modifications et les signalent à l'utilisateur à l'aide de la bibliothèque de résumé des mutations de Rafael. Cela garantit des performances optimales. La page entière n'est pas envoyée pour chaque frame.

Comme le souligne Rafael dans la vidéo, il ne s'agit que d'une démonstration de faisabilité. Pourtant, je pense que c'est un bon moyen de combiner une fonctionnalité plus récente de la plate-forme, comme les Observateurs de mutation, avec une fonctionnalité plus ancienne comme Websockets.

Méthode 2: objet blob à partir d'un HTMLDocument + Binary WebSocket

La méthode suivante m'est venue récemment. Cette méthode est semblable à l'approche "Observateurs de mutation", mais au lieu d'envoyer des différences récapitulatives, elle crée un clone Blob de l'ensemble de l'élément HTMLDocument et l'envoie sur un Websocket binaire. Voici la procédure de configuration:

  1. Réécrivez toutes les URL de la page afin qu'elles soient absolues. Cela permet d'éviter que les images statiques et les éléments CSS contiennent des liens non fonctionnels.
  2. Clonez l'élément de document de la page: document.documentElement.cloneNode(true);.
  3. Rendez le clone en lecture seule, non sélectionnable, et empêchez le défilement à l'aide du CSS pointer-events: 'none';user-select:'none';overflow:hidden;.
  4. Capturez la position de défilement actuelle de la page et ajoutez-les en tant qu'attributs data-* sur le doublon.
  5. Créez un new Blob() à partir de l'élément .outerHTML du doublon.

Le code ressemble à ceci (j'ai apporté des simplifications à partir de la source complète):

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() contient des expressions régulières simples permettant de réécrire des URL relatives/sans schéma en URL absolues. Cela est nécessaire pour que les images, les fichiers CSS, les polices et les scripts continuent à fonctionner correctement lorsqu'ils sont affichés dans le contexte d'une URL d'objet blob (par exemple, depuis une origine différente).

Une dernière modification que j'ai apportée consiste à ajouter la prise en charge du défilement. Lorsque le présentateur fait défiler la page, l'utilisateur doit suivre. Pour ce faire, je cache les positions actuelles scrollX et scrollY en tant qu'attributs data-* dans le HTMLDocument en double. Avant la création de l'objet blob final, un code JavaScript est injecté et se déclenche lors du chargement de la page:

// 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);
    });

L'imitation du défilement donne l'impression que nous avons fait une capture d'écran d'une partie de la page d'origine alors qu'en fait, nous avons dupliqué l'intégralité de la page et l'avons simplement repositionnée. #clever

Démonstration

Toutefois, pour le partage d'onglets, nous devons capturer l'onglet en continu et l'envoyer aux spectateurs. Pour cela, j'ai écrit un petit serveur WebSocket Node, une application et un favorilet qui illustre le flux. Si le code ne vous intéresse pas, voici une courte vidéo illustrant son fonctionnement:

Améliorations futures

Une optimisation consiste à ne pas dupliquer l'intégralité du document sur chaque cadre. C'est du gaspillage, et c'est un problème que l'observateur de mutations fait bien. Une autre amélioration consiste à gérer les images d'arrière-plan CSS relatives dans urlsToAbsolute(). C'est quelque chose que le script actuel ne prend pas en compte.

Méthode 3: API Chrome Extension + Binary WebSocket

Lors de la conférence Google I/O 2012, j'ai présenté une autre approche pour partager l'écran d'un onglet de navigateur. Cependant, celui-ci est une tricherie. Elle nécessite une API Chrome Extension, ce qui n'est pas le cas du HTML5 pur.

La source de celui-ci est également disponible sur GitHub, mais en bref:

  1. Capture l'onglet actif au format .png dataURL. Les extensions Chrome disposent d'une API pour chrome.tabs.captureVisibleTab().
  2. Convertissez l'URL de données en Blob. Consultez l'outil d'aide convertDataURIToBlob().
  3. Envoyez chaque objet blob (frame) à la visionneuse à l'aide d'un WebSocket binaire en définissant socket.responseType='blob'.

Exemple

Voici le code permettant de faire une capture d'écran de l'onglet actuel au format png et d'envoyer le cadre via 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);

Améliorations futures

La fréquence d'images est étonnamment bonne pour cette vidéo, mais elle pourrait être encore meilleure. Une amélioration consiste à supprimer le temps système lié à la conversion de dataURL en objet Blob. Malheureusement, chrome.tabs.captureVisibleTab() ne nous fournit qu'une URL de données. S'il renvoyait un objet Blob ou Typed Array, nous pourrions l'envoyer directement via le WebSocket, au lieu d'effectuer nous-mêmes la conversion en un objet Blob. Pour cela, ajoutez crbug.com/32498 à vos favoris.

Méthode 4 : WebRTC : le véritable avenir

Dernier point, mais non des moindres !

L'avenir du partage d'écran dans le navigateur sera réalisé par WebRTC. Le 14 août 2012, l'équipe a proposé une API WebRTC Tab Content Capture pour partager le contenu des onglets:

Jusqu'à ce que ce type soit prêt, il nous reste les méthodes 1 à 3.

Conclusion

Ainsi, le partage d'onglets de navigateur est possible grâce à la technologie Web d'aujourd'hui !

Mais... cette affirmation doit être prise avec un grain de sel. Bien que parfaites, les techniques présentées dans cet article ne sont pas très efficaces pour partager l'expérience utilisateur, d'une manière ou d'une autre. Tout va changer grâce à l'effort de capture du contenu de l'onglet WebRTC, mais jusqu'à ce que ce soit une réalité, il nous reste des plug-ins de navigateur ou des solutions limitées comme celles présentées ici.

Vous avez d'autres techniques ? Publiez un commentaire !