HTML5에서 브라우저 탭을 화면공유하는 경우

지난 몇 년 동안 저는 브라우저 기술만 사용하여 화면 공유와 같은 기능을 구현하는 데 도움을 주었습니다. 제 경험상 웹 플랫폼 기술만으로 VNC를 구현하는 것은(즉, 플러그인 없이) 어려운 문제입니다. 고려해야 할 사항도 많고 극복해야 할 과제도 많습니다. 마우스 포인터 위치 중계, 키 입력 전달, 60fps에서 전체 24비트 색상 다시 그리기는 몇 가지 문제에 불과합니다.

탭 콘텐츠 캡처 중

기존 화면 공유의 복잡성을 제거하고 브라우저 탭의 콘텐츠 공유에 집중하면 문제가 크게 단순화되어 a.) 현재 상태의 표시되는 탭을 캡처하고 b.) 해당 '프레임'을 유선으로 전송할 수 있습니다. 기본적으로 DOM의 스냅샷을 찍고 공유할 방법이 필요합니다.

공유하는 부분은 간단합니다. WebSockets는 다양한 형식(문자열, JSON, 바이너리)으로 데이터를 전송할 수 있습니다. 스냅샷 생성 부분은 훨씬 더 어려운 문제입니다. html2canvas와 같은 프로젝트는 브라우저의 렌더링 엔진을 JavaScript로 다시 구현하여 HTML 화면 캡처를 처리했습니다. 또 다른 예는 Google 피드백입니다. 오픈소스는 아니지만, 이러한 유형의 프로젝트는 매우 멋지지만 속도가 끔찍할 정도로 느립니다. 1fps 처리량을 얻는 것도 운이 좋은 것이며, 60fps는 말할 것도 없습니다.

이 도움말에서는 탭을 '화면 공유'하기 위한 몇 가지 개념 증명 솔루션을 설명합니다.

방법 1: Mutation Observer + WebSocket

올해 초 +Rafael Weinstein이 탭 미러링 방법을 시연한 바 있습니다. 이 기법은 Mutation Observers와 WebSocket을 사용합니다.

기본적으로 발표자가 공유하는 탭은 페이지의 변경사항을 감시하고 웹소켓을 사용하여 뷰어에 차이점을 전송합니다. 사용자가 페이지를 스크롤하거나 페이지와 상호작용하면 옵저버가 이러한 변경사항을 포착하고 라파엘의 변형 요약 라이브러리를 사용하여 뷰어에 다시 보고합니다. 이렇게 하면 성능이 유지됩니다. 프레임마다 전체 페이지가 전송되지는 않습니다.

라파엘이 동영상에서 지적했듯이 이는 개념 증명에 불과합니다. 그래도 Mutation Observer와 같은 최신 플랫폼 기능과 Websocket과 같은 이전 기능을 결합하는 깔끔한 방법이라고 생각합니다.

방법 2: HTMLDocument의 Blob + 바이너리 WebSocket

다음 방법은 최근에 깨달은 방법입니다. Mutation Observers 접근 방식과 유사하지만 요약 차이를 전송하는 대신 전체 HTMLDocument의 Blob 클론을 생성하고 바이너리 웹소켓을 통해 전송합니다. 설정 단계는 다음과 같습니다.

  1. 페이지의 모든 URL을 절대 URL로 다시 작성합니다. 이렇게 하면 정적 이미지 및 CSS 애셋에 깨진 링크가 포함되지 않습니다.
  2. 페이지의 문서 요소를 클론합니다. document.documentElement.cloneNode(true);
  3. CSS를 사용하여 클론을 읽기 전용, 선택 불가로 만들고 스크롤을 방지합니다. pointer-events: 'none';user-select:'none';overflow:hidden;
  4. 페이지의 현재 스크롤 위치를 캡처하고 중복 페이지에 data-* 속성으로 추가합니다.
  5. 중복 항목의 .outerHTML에서 new Blob()을(를) 만듭니다.

코드는 다음과 같습니다(전체 소스에서 간소화함).

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()에는 상대/스키마 없는 URL을 절대 URL로 재작성하는 간단한 정규식이 포함되어 있습니다. 이는 blob URL의 컨텍스트(예: 다른 출처)에서 볼 때 이미지, CSS, 글꼴, 스크립트가 손상되지 않도록 하기 위해 필요합니다.

마지막으로 스크롤 지원을 추가했습니다. 발표자가 페이지를 스크롤하면 시청자도 따라가야 합니다. 이를 위해 현재 scrollX 및 scrollY 위치를 중복 HTMLDocument의 data-* 속성으로 숨깁니다. 최종 Blob이 생성되기 전에 페이지 로드 시 실행되는 약간의 JS가 삽입됩니다.

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

스크롤을 가짜로 만들면 원본 페이지의 일부를 스크린샷한 것처럼 보이지만 실제로는 전체를 복제하고 위치만 변경한 것입니다. #clever

데모

하지만 탭 공유의 경우 탭을 지속적으로 캡처하여 시청자에게 전송해야 합니다. 이를 위해 흐름을 보여주는 작은 Node websocket 서버, 앱, 북마클릿을 작성했습니다. 코드에 관심이 없다면 다음의 짧은 동영상을 통해 실제 작동 모습을 확인하세요.

향후 개선사항

한 가지 최적화는 프레임마다 전체 문서를 복제하지 않는 것입니다. 이는 낭비이며 Mutation Observer 예시가 잘하는 부분입니다. 또 다른 개선사항은 urlsToAbsolute()에서 상대 CSS 배경 이미지를 처리하는 것입니다. 현재 스크립트에서는 이 점을 고려하지 않습니다

방법 3: Chrome 확장 프로그램 API + 바이너리 WebSocket

Google I/O 2012에서 브라우저 탭의 콘텐츠를 화면 공유하는 또 다른 접근 방식을 시연했습니다. 하지만 이 방법은 편법입니다. 순수한 HTML5 마법이 아닌 Chrome 확장 프로그램 API가 필요합니다.

이 예시의 소스도 GitHub에 있지만 요점은 다음과 같습니다.

  1. 현재 탭을 .png dataURL로 캡처합니다. Chrome 확장 프로그램에는 이를 위한 API가 있습니다(chrome.tabs.captureVisibleTab()).
  2. dataURL을 Blob로 변환합니다. convertDataURIToBlob() 도우미를 참고하세요.
  3. socket.responseType='blob'를 설정하여 바이너리 WebSocket을 통해 각 Blob(프레임)을 뷰어로 전송합니다.

예

다음은 현재 탭을 png로 스크린샷하고 웹소켓을 통해 프레임을 전송하는 코드입니다.

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

향후 개선사항

이 게임의 프레임 속도는 놀라울 정도로 좋지만 더 개선할 수 있습니다. 한 가지 개선사항은 dataURL을 Blob으로 변환하는 오버헤드를 제거하는 것입니다. 안타깝게도 chrome.tabs.captureVisibleTab()는 dataURL만 제공합니다. Blob 또는 Typed Array를 반환했다면 Blob으로 직접 변환하는 대신 웹소켓을 통해 직접 전송할 수 있습니다. crbug.com/32498에 별표표시를 하여 이 기능이 구현되도록 도와주세요.

방법 4: WebRTC - 진정한 미래

마지막으로

브라우저에서 화면 공유의 미래는 WebRTC를 통해 실현될 것입니다. 2012년 8월 14일, 팀은 탭 콘텐츠를 공유하기 위한 WebRTC Tab Content Capture API를 제안했습니다.

이 방법이 준비될 때까지는 방법 1~3을 사용해야 합니다.

결론

따라서 오늘날의 웹 기술로 브라우저 탭 공유가 가능합니다.

하지만 이 말은 곧이곧대로 받아들여서는 안 됩니다. 이 도움말의 기법은 깔끔하지만 어떤 식으로든 훌륭한 공유 UX에 미치지 못합니다. WebRTC 탭 콘텐츠 캡처를 사용하면 이 모든 것이 달라지지만, 실제로 구현되기 전까지는 브라우저 플러그인이나 여기에서 다루는 것과 같은 제한된 솔루션을 사용해야 합니다.

다른 기법이 있나요? 댓글을 남겨주세요.