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

Ces deux dernières années, j'ai aidé plusieurs entreprises à mettre en place une fonctionnalité de partage d'écran en utilisant uniquement des technologies de navigateur. D'après mon expérience, implémenter VNC uniquement dans les technologies de plate-forme Web (c'est-à-dire sans plug-ins) est un problème difficile. Il y a beaucoup de choses à prendre en compte et de nombreux défis à relever. Relayer la position du pointeur de la souris, transférer les frappes au clavier et obtenir des rafraîchissements d'écran en couleurs 24 bits à 60 fps ne sont que quelques-uns des problèmes.

Capture du contenu de l'onglet…

Si nous éliminons les complexités du partage d'écran traditionnel et que nous nous concentrons sur le partage du contenu d'un onglet de navigateur, le problème se simplifie considérablement : a) capturer l'onglet visible dans son état actuel et b) envoyer cette "image" sur le réseau. En gros, nous avons besoin d'un moyen de prendre un instantané du DOM et de le partager.

Le partage est simple. Les WebSockets sont tout à fait capables d'envoyer des données dans différents formats (chaîne, JSON, binaire). La partie instantané est un problème beaucoup plus difficile. Des projets comme html2canvas ont permis de capturer du code HTML en réimplémentant le moteur de rendu du navigateur… en JavaScript ! Google Feedback est un autre exemple, bien qu'il ne soit pas Open Source. Ces types de projets sont très intéressants, mais ils sont aussi terriblement lents. Vous auriez de la chance d'obtenir un débit de 1 fps, et encore moins les 60 fps tant convoités.

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 : MutationObserver + WebSocket

+Rafael Weinstein a présenté une approche permettant de dupliquer un onglet plus tôt cette année. Sa technique utilise des observateurs de mutation et un WebSocket.

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

Comme Rafael l'indique dans la vidéo, il ne s'agit que d'une démonstration de faisabilité. Néanmoins, je pense que c'est un bon moyen de combiner une fonctionnalité de plate-forme plus récente, comme les observateurs de mutation, avec une fonctionnalité plus ancienne, comme les WebSockets.

Méthode 2 : Blob à partir d'un HTMLDocument + WebSocket binaire

La méthode suivante m'est venue à l'esprit récemment. Cette approche est semblable à celle des Mutation Observers, mais au lieu d'envoyer des différences récapitulatives, elle crée un clone Blob de l'intégralité du HTMLDocument et l'envoie via un WebSocket binaire. Voici la procédure à suivre :

  1. Réécrivez toutes les URL de la page pour qu'elles soient absolues. Cela évite que les composants d'image et CSS statiques 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 de la propriété CSS pointer-events: 'none';user-select:'none';overflow:hidden;.
  4. Capturez la position de défilement actuelle de la page et ajoutez-la en tant qu'attributs data-* sur le double.
  5. Créez un new Blob() à partir du .outerHTML du doublon.

Le code ressemble à ceci (j'ai simplifié le code source complet) :

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 pour réécrire les URL relatives/sans schéma en URL absolues. Cela est nécessaire pour que les images, les fichiers CSS, les polices et les scripts ne soient pas endommagés lorsqu'ils sont affichés dans le contexte d'une URL de blob (par exemple, à partir d'une autre origine).

La dernière modification que j'ai apportée a été d'ajouter la prise en charge du défilement. Lorsque le présentateur fait défiler la page, le spectateur doit suivre. Pour ce faire, je stocke les positions scrollX et scrollY actuelles en tant qu'attributs data-* sur l'élément HTMLDocument dupliqué. Avant la création du blob final, un peu de code JS est injecté et se déclenche au chargement de 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);
    });

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

Démo

Mais 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 favori intelligent qui illustrent le flux. Si vous n'êtes pas intéressé par le code, voici une courte vidéo de l'application en action :

Améliorations futures

Une optimisation consiste à ne pas dupliquer l'intégralité du document sur chaque image. C'est du gaspillage, et l'exemple Mutation Observer s'en sort très bien. Une autre amélioration consiste à gérer les images de fond CSS relatives dans urlsToAbsolute(). Le script actuel ne prend pas cela en compte.

Méthode 3 : API d'extension Chrome + WebSocket binaire

Lors de la conférence Google I/O 2012, j'ai présenté une autre approche pour partager l'écran du contenu d'un onglet de navigateur. Cependant, celui-ci est une triche. Elle nécessite une API d'extension Chrome, et non une simple magie HTML5.

Le code source de cet exemple est également disponible sur GitHub, mais l'essentiel est le suivant :

  1. Capturez l'onglet actuel en tant que dataURL .png. Les extensions Chrome disposent d'une API pour cela : chrome.tabs.captureVisibleTab().
  2. Convertissez le dataURL en Blob. Consultez l'outil d'aide convertDataURIToBlob().
  3. Envoyez chaque Blob (frame) au lecteur à 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 l'image 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 ce jeu, mais elle pourrait être encore meilleure. Une amélioration consisterait à supprimer la surcharge liée à la conversion de l'URL de données en blob. Malheureusement, chrome.tabs.captureVisibleTab() ne nous fournit qu'une dataURL. Si elle renvoyait un blob ou un tableau typé, nous pourrions l'envoyer directement via le websocket plutôt que d'effectuer nous-mêmes la conversion en blob. Veuillez ajouter crbug.com/32498 à vos favoris pour que cela se produise.

Méthode 4 : WebRTC, l'avenir de la diffusion

Dernier point, mais non des moindres !

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

En attendant que cette fonctionnalité soit prête, nous devons nous contenter des méthodes 1 à 3.

Conclusion

Le partage d'onglets de navigateur est donc possible avec la technologie Web actuelle.

Mais… cette affirmation doit être prise avec des pincettes. Bien que soignées, les techniques présentées dans cet article ne permettent pas d'offrir une expérience utilisateur de partage optimale. Tout cela changera avec l'initiative WebRTC Tab Content Capture, mais en attendant, nous devons nous contenter de plug-ins de navigateur ou de solutions limitées comme celles présentées ici.

Vous avez d'autres techniques ? Publiez un commentaire !