לשתף את המסך של כרטיסיית דפדפן ב-HTML5?

בשנים האחרונות עזרתי לכמה חברות להשיג פונקציונליות דומה לשיתוף מסך באמצעות טכנולוגיות דפדפן בלבד. מניסיוני, הטמעת VNC רק בטכנולוגיות של פלטפורמות אינטרנט (כלומר, ללא יישומי פלאגין) היא בעיה קשה. יש הרבה דברים שצריך לקחת בחשבון והרבה אתגרים שצריך להתגבר עליהם. העברת המיקום של סמן העכבר, העברת הקשות על המקשים וצביעת מחדש מלאה של 24 ביט צבע ב-60fps הן רק חלק מהבעיות.

צילום תוכן הכרטיסייה

אם מסירים את המורכבות של שיתוף המסך המסורתי ומתמקדים בשיתוף התוכן של כרטיסיית דפדפן, הבעיה מתפשטת מאוד: א. צילום הכרטיסייה הגלויה במצב הנוכחי שלה, ב. שליחת 'הפריים' הזה דרך החיבור. בעיקרון, אנחנו צריכים דרך לצלם את DOM ולשתף אותו.

החלק של השיתוף קל. ב-Websockets יש יכולת רבה לשלוח נתונים בפורמטים שונים (מחרוזת, JSON, בינאריים). החלק של יצירת קובצי snapshot הוא בעיה קשה הרבה יותר. פרויקטים כמו html2canvas ניסו לפתור את הבעיה של צילום מסך של HTML על ידי הטמעה מחדש של מנוע הרינדור של הדפדפן… ב-JavaScript! דוגמה נוספת היא Google Feedback, אבל היא לא בקוד פתוח. פרויקטים כאלה מאוד מגניבים, אבל הם גם איטיים מאוד. אם תהיה לכם מזל, תקבלו קצב העברת נתונים של 1fps, שלא לדבר על 60fps.

במאמר הזה אסקור כמה מהפתרונות המועדפים עליי להוכחת היתכנות של 'שיתוף מסך' של כרטיסייה.

שיטה 1: Mutation Observers +‏ WebSocket

Rafael Weinstein הדגים גישה אחת לשיקוף כרטיסייה מוקדם יותר השנה. הטכניקה שלו כוללת שימוש ב-Mutation Observers וב-WebSocket.

במילים פשוטות, הכרטיסייה שהמנחה משתף עוקבת אחרי שינויים בדף ושולחת את ההבדלים לצופים באמצעות WebSocket. כשהמשתמש גולל או יוצר אינטראקציה עם הדף, המתבוננים מזהים את השינויים האלה ומדווחים עליהם לצופה באמצעות ספריית הסיכום של המוטציות של Rafael. כך אפשר לשמור על ביצועים טובים. הדף כולו לא נשלח בכל פריים.

כפי שרפאל מציין בסרטון, מדובר רק בהוכחת היתכנות. עם זאת, לדעתי זו דרך נהדרת לשלב תכונה חדשה יותר בפלטפורמה, כמו Mutation Observers, עם תכונה ישנה יותר, כמו Websockets.

שיטה 2: Blob מ-HTMLDocument + WebSocket בינארי

השיטה הבאה היא שיטה שגיליתי לאחרונה. הגישה הזו דומה לגישה של Mutation Observers, אבל במקום לשלוח סיכומי שינויים, היא יוצרת עותק משובך (clone) של ה-Blob של כל HTMLDocument ושולחת אותו דרך websocket בינארי. כך מגדירים את ההגדרות לפי סוג:

  1. כותבים מחדש את כל כתובות ה-URL בדף ככתובות מוחלטות. כך מונעים מנכסי CSS ותמונות סטטיות להכיל קישורים לא תקינים.
  2. מעתיקים את רכיב המסמך של הדף: document.documentElement.cloneNode(true);
  3. איך הופכים את העותק לקריא בלבד, לא ניתן לבחירה ומונעים גלילה באמצעות CSS pointer-events: 'none';user-select:'none';overflow:hidden;
  4. מתעדים את מיקום הגלילה הנוכחי של הדף ומוסיפים אותו כמאפייני data-* בדף הכפול.
  5. יוצרים new Blob() מה-.outerHTML של העותק הכפול.

הקוד נראה בערך כך (הפשטתי אותו מהמקור המלא):

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 ללא סכימה לכתובות URL מוחלטות. הדבר נחוץ כדי שתמונות, רכיבי CSS, גופנים וסקריפטים לא יתקלקלו כשהם מוצגים בהקשר של כתובת URL של blob (למשל, ממקור אחר).

שינוי אחרון שביצעתי היה הוספת תמיכה בגלילה. כשהמגיש גולל בדף, הצופים צריכים לגלול אחריו. כדי לעשות זאת, שומרים את המיקומים הנוכחיים scrollX ו-scrollY כמאפייני data-* בעותק הכפול HTMLDocument. לפני יצירת ה-Blob הסופי, מוזן קטע קוד של JavaScript שמופעל בטעינת הדף:

// 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 פותרת. שיפור נוסף הוא הטיפול בתמונות רקע יחסיות ב-CSS ב-urlsToAbsolute(). זהו משהו שהסקריפט הנוכחי לא מתייחס אליו.

שיטה 3: Chrome Extension API + Binary WebSocket

ב-Google I/O 2012, הראיתי גישה אחרת לשיתוף המסך של תוכן בכרטיסייה בדפדפן. עם זאת, זו דרך קצרה וקלה. נדרשת API של תוסף Chrome: לא רק קסם HTML5.

המקור של הקוד הזה זמין גם ב-GitHub, אבל העיקרון הוא:

  1. צילום הכרטיסייה הנוכחית כ-dataURL מסוג ‎ .png. לתוספים ל-Chrome יש ממשק API לצורך כך chrome.tabs.captureVisibleTab().
  2. ממירים את ה-dataURL ל-Blob. אפשר לעיין במאמר העזרה בנושא convertDataURIToBlob().
  3. שולחים כל Blob (מסגרת) לצופה באמצעות WebSocket בינארי על ידי הגדרת socket.responseType='blob'.

דוגמה

הנה קוד לצילום מסך של הכרטיסייה הנוכחית כקובץ PNG ושליחת המסגרת דרך 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);

שיפורים עתידיים

קצב הפריימים טוב להפתיע בסרטון הזה, אבל הוא יכול להיות טוב יותר. שיפור אחד יכול להיות הסרת העלויות הנלוות של המרת ה-dataURL ל-Blob. לצערנו, chrome.tabs.captureVisibleTab() מספק לנו רק את dataURL. אם הוא מחזיר Blob או מערך Typed, נוכל לשלוח אותו ישירות דרך ה-WebSocket במקום לבצע את ההמרה ל-Blob בעצמנו. כדי שזה יקרה, אפשר לסמן בכוכב את הבעיה ב-crbug.com/32498.

שיטה 4: WebRTC – העתיד האמיתי

ואחרון חביב!

WebRTC הוא העתיד של שיתוף המסך בדפדפן. ב-14 באוגוסט 2012, הצוות הציע ממשק API לתיעוד תוכן הכרטיסייה ב-WebRTC לשיתוף תוכן הכרטיסייה:

עד שהוא יהיה מוכן, נישאר עם השיטות 1-3.

סיכום

כך אפשר לשתף כרטיסיות בדפדפן בעזרת טכנולוגיית האינטרנט של היום.

אבל… צריך להתייחס לטענה הזו בזהירות. הטכניקות שמפורטות במאמר הזה הן יעילות, אבל בכל זאת הן לא מספקות חוויית משתמש נהדרת לשיתוף. כל זה ישתנה עם המאמץ של WebRTC לתיעוד תוכן הכרטיסייה, אבל עד שזה יקרה, נישאר עם יישומי פלאגין לדפדפן או פתרונות מוגבלים כמו אלה שמפורטים כאן.

רוצים לדעת עוד שיטות? אפשר לכתוב תגובה!