הפעלה מהירה באמצעות טעינה מראש של אודיו ווידאו

איך להאיץ את הפעלת המדיה על ידי טעינה מראש של משאבים באופן פעיל.

פרנסואה בופורט
פרנסואה בופורט

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

קרדיטים: זכויות יוצרים: קרן Blaender Foundation | www.blender.org .

אתאר שלוש שיטות לטעינה מראש של קובצי מדיה, אתחיל ביתרונות והחסרונות שלהן.

זה מעולה... אבל...
מאפיין הטעינה מראש של סרטון קל לשימוש עבור קובץ ייחודי שמתארח בשרת אינטרנט. דפדפנים עלולים להתעלם מהמאפיין לחלוטין.
אחזור המשאבים מתחיל כשמסמך ה-HTML נטען ונותח במלואו.
תוספים של מקור מדיה (MSE) מתעלמים מהמאפיין preload ברכיבי מדיה כי האפליקציה אחראית לספק מדיה ל-MSE.
טעינה מראש של קישור ההגדרה הזו מאלצת את הדפדפן לשלוח בקשה למשאב וידאו בלי לחסום את האירוע onload של המסמך. בקשות לטווח HTTP לא תואמות.
תואם ל-MSE ולפלחים של קבצים. מיועד לשימוש רק בקובצי מדיה קטנים (פחות מ-5MB) כשמנסים לאחזר משאבים מלאים.
אחסון נתונים זמני באופן ידני שליטה מלאה הטיפול בשגיאות מורכבות הוא באחריות האתר.

מאפיין טעינה מראש של סרטון

אם מקור הווידאו הוא קובץ ייחודי שמתארח בשרת אינטרנט, כדאי להשתמש במאפיין הווידאו preload כדי לספק רמז לדפדפן לגבי כמות המידע או התוכן שצריך לטעון מראש. המשמעות היא שתוספים של מקור מדיה (MSE) לא תואמים ל-preload.

אחזור המשאבים יתחיל רק כשמסמך ה-HTML הראשוני נטען ונותח במלואו (למשל, האירוע DOMContentLoaded הופעל) כשאירוע load השונה מאוד יופעל כשאוחזר המשאב בפועל.

הגדרת המאפיין preload ל-metadata מציינת שלא צפוי שהמשתמש יזדקק לסרטון, אבל רצוי לאחזר את המטא-נתונים שלו (מאפיינים, רשימת טראקים, משך זמן וכו'). שימו לב שהחל מ-Chrome 64, ערך ברירת המחדל של preload הוא metadata. (זה היה auto בעבר).

<video id="video" preload="metadata" src="file.mp4" controls></video>

<script>
  video.addEventListener('loadedmetadata', function() {
    if (video.buffered.length === 0) return;

    const bufferedSeconds = video.buffered.end(0) - video.buffered.start(0);
    console.log(`${bufferedSeconds} seconds of video are ready to play.`);
  });
</script>

אם מגדירים את המאפיין preload לערך auto, הדפדפן עשוי לשמור מספיק נתונים כדי לסיים את ההפעלה בלי לדרוש עצירה לאגירת נתונים נוספת.

<video id="video" preload="auto" src="file.mp4" controls></video>

<script>
  video.addEventListener('loadedmetadata', function() {
    if (video.buffered.length === 0) return;

    const bufferedSeconds = video.buffered.end(0) - video.buffered.start(0);
    console.log(`${bufferedSeconds} seconds of video are ready to play.`);
  });
</script>

עם זאת, יש כמה אזהרות. זהו רמז בלבד, הדפדפן עשוי להתעלם לגמרי מהמאפיין preload. בזמן הכתיבה, הנה כמה כללים החלים ב-Chrome:

  • כשהתכונה Data Saver מופעלת, Chrome מאלץ את הערך preload לערך none.
  • ב-Android 4.3, Chrome מאלץ את הערך preload לערך none בגלל באג ב-Android.
  • בחיבור לרשת סלולרית (2G, 3G ו-4G), דפדפן Chrome מאלץ את הערך preload ל-metadata.

טיפים

אם האתר שלך מכיל משאבי וידאו רבים באותו דומיין, מומלץ להגדיר את הערך של preload ל-metadata או להגדיר את המאפיין poster ולהגדיר ל-preload את הערך none. כך אפשר למנוע הגעה למספר המקסימלי של חיבורי HTTP לאותו הדומיין (6 בהתאם למפרט של HTTP 1.1), שיכול לתלות את הטעינה של משאבים. שים לב: הפעולה הזו עשויה גם לשפר את מהירות הדף אם סרטונים הם לא חלק מחוויית המשתמש העיקרית שלך.

כפי שמופיע במאמרים אחרים, טעינה מראש של קישור היא אחזור הצהרתי שמאפשר לאלץ את הדפדפן להגיש בקשה למשאב בלי לחסום את האירוע load ובמהלך הורדת הדף. משאבים שנטענים דרך <link rel="preload"> מאוחסנים באופן מקומי בדפדפן, והם יציבים עד שמתבצעת הפניה אליהם במפורש ב-DOM, ב-JavaScript או ב-CSS.

הטעינה מראש שונה משליפה מראש (prefetch) בכך שהיא מתמקדת בניווט הנוכחי ומאחזרת משאבים עם עדיפות על סמך הסוג שלהם (סקריפט, סגנון, גופן, וידאו, אודיו וכו'). יש להשתמש בו כדי לחמם את המטמון של הדפדפן להפעלות הנוכחיות.

טעינה מראש של הסרטון המלא

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

<link rel="preload" as="video" href="https://cdn.com/small-file.mp4">

<video id="video" controls></video>

<script>
  // Later on, after some condition has been met, set video source to the
  // preloaded video URL.
  video.src = 'https://cdn.com/small-file.mp4';
  video.play().then(() => {
    // If preloaded video URL was already cached, playback started immediately.
  });
</script>

מכיוון שרכיב וידאו בדוגמה זו ינצל את המשאב שנטען מראש, ערך הקישור של הטעינה מראש as הוא video. אם זה היה רכיב אודיו, הוא היה as="audio".

טעינה מראש של המקטע הראשון

הדוגמה הבאה מראה איך לטעון מראש את הקטע הראשון של הסרטון באמצעות <link rel="preload"> ולהשתמש בו עם תוספים של מקור מדיה. אם אתם לא מכירים את MSE JavaScript API, כדאי לעיין במאמר היסודות של MSE.

לצורך הפשטות, נניח שכל הסרטון פוצל לקבצים קטנים יותר כמו file_1.webm, file_2.webm, file_3.webm וכו'.

<link rel="preload" as="fetch" href="https://cdn.com/file_1.webm">

<video id="video" controls></video>

<script>
  const mediaSource = new MediaSource();
  video.src = URL.createObjectURL(mediaSource);
  mediaSource.addEventListener('sourceopen', sourceOpen, { once: true });

  function sourceOpen() {
    URL.revokeObjectURL(video.src);
    const sourceBuffer = mediaSource.addSourceBuffer('video/webm; codecs="vp09.00.10.08"');

    // If video is preloaded already, fetch will return immediately a response
    // from the browser cache (memory cache). Otherwise, it will perform a
    // regular network fetch.
    fetch('https://cdn.com/file_1.webm')
    .then(response => response.arrayBuffer())
    .then(data => {
      // Append the data into the new sourceBuffer.
      sourceBuffer.appendBuffer(data);
      // TODO: Fetch file_2.webm when user starts playing video.
    })
    .catch(error => {
      // TODO: Show "Video is not available" message to user.
    });
  }
</script>

תמיכה

תוכלו לזהות תמיכה בסוגים שונים של as עבור <link rel=preload> באמצעות קטעי הקוד הבאים:

function preloadFullVideoSupported() {
  const link = document.createElement('link');
  link.as = 'video';
  return (link.as === 'video');
}

function preloadFirstSegmentSupported() {
  const link = document.createElement('link');
  link.as = 'fetch';
  return (link.as === 'fetch');
}

אגירת נתונים ידנית

לפני שנתעמק ב-Cache API וב-service worker, נראה כיצד לאגור סרטון באופן ידני באמצעות MSE. בדוגמה הבאה ההנחה היא ששרת האינטרנט שלכם תומך בבקשות HTTP Range, אבל זה יהיה די דומה עם פלחי קבצים. שימו לב שחלק מהספריות של תוכנות מתווכות, כמו shaka Player, JW Player ו-Video.js של Google, מותאמות לפתרון בשבילכם.

<video id="video" controls></video>

<script>
  const mediaSource = new MediaSource();
  video.src = URL.createObjectURL(mediaSource);
  mediaSource.addEventListener('sourceopen', sourceOpen, { once: true });

  function sourceOpen() {
    URL.revokeObjectURL(video.src);
    const sourceBuffer = mediaSource.addSourceBuffer('video/webm; codecs="vp09.00.10.08"');

    // Fetch beginning of the video by setting the Range HTTP request header.
    fetch('file.webm', { headers: { range: 'bytes=0-567139' } })
    .then(response => response.arrayBuffer())
    .then(data => {
      sourceBuffer.appendBuffer(data);
      sourceBuffer.addEventListener('updateend', updateEnd, { once: true });
    });
  }

  function updateEnd() {
    // Video is now ready to play!
    const bufferedSeconds = video.buffered.end(0) - video.buffered.start(0);
    console.log(`${bufferedSeconds} seconds of video are ready to play.`);

    // Fetch the next segment of video when user starts playing the video.
    video.addEventListener('playing', fetchNextSegment, { once: true });
  }

  function fetchNextSegment() {
    fetch('file.webm', { headers: { range: 'bytes=567140-1196488' } })
    .then(response => response.arrayBuffer())
    .then(data => {
      const sourceBuffer = mediaSource.sourceBuffers[0];
      sourceBuffer.appendBuffer(data);
      // TODO: Fetch further segment and append it.
    });
  }
</script>

לתשומת ליבכם

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

מוּדעוּת לסוללה

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

צריך להשבית את הטעינה מראש או לפחות לטעון מראש סרטון ברזולוציה נמוכה יותר כשהסוללה במכשיר מתרוקנת.

if ('getBattery' in navigator) {
  navigator.getBattery()
  .then(battery => {
    // If battery is charging or battery level is high enough
    if (battery.charging || battery.level > 0.15) {
      // TODO: Preload the first segment of a video.
    }
  });
}

זיהוי "חוסך הנתונים"

השתמשו בכותרת Save-Data של בקשת רמז לקוח כדי לספק אפליקציות מהירות ונוחות למשתמשים שהביעו הסכמה למצב 'חיסכון בנתונים' בדפדפן שלהם. זיהוי כותרת הבקשה הזו מאפשר לאפליקציה להתאים אישית ולספק חוויית משתמש אופטימלית למשתמשים בהגבלת עלות וביצועים.

למידע נוסף, ראו שימוש באפליקציות מהירות ומהירות באמצעות 'שמירת נתונים'.

טעינה חכמה על סמך פרטי הרשת

מומלץ לבדוק את navigator.connection.type לפני הטעינה מראש. אם היא מוגדרת לערך cellular, אפשר למנוע טעינה מראש ולהודיע למשתמשים שהספק של הרשת הסלולרית שלהם עשוי לדרוש תשלום על רוחב הפס, ורק להתחיל הפעלה אוטומטית של תוכן שנשמר במטמון קודם לכן.

if ('connection' in navigator) {
  if (navigator.connection.type == 'cellular') {
    // TODO: Prompt user before preloading video
  } else {
    // TODO: Preload the first segment of a video.
  }
}

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

שמירה מראש של מספר קטעים ראשונים במטמון

מה קורה אם רוצים לטעון מראש באופן ספקולטיבי תוכן מדיה מסוים בלי לדעת איזה קטע מדיה המשתמש יבחר בסופו של דבר? אם המשתמש נמצא בדף אינטרנט שיש בו 10 סרטונים, סביר להניח שיש לנו מספיק זיכרון כדי לאחזר קובץ פלח אחד מכל אחד, אבל לא מומלץ ליצור 10 רכיבי <video> מוסתרים ו-10 אובייקטים של MediaSource ולהתחיל להזין את הנתונים האלה.

הדוגמה בת שני החלקים הבאה ממחישה כיצד לשמור מראש כמה קטעים ראשונים של הסרטון באמצעות מטמון API העוצמתי והקל לשימוש. שימו לב שאפשר להשיג משהו דומה גם באמצעות IndexedDB. אנחנו עדיין לא משתמשים ב-Service Workers כי אפשר לגשת ל-Cache API גם מהאובייקט window.

אחזור ומטמון

const videoFileUrls = [
  'bat_video_file_1.webm',
  'cow_video_file_1.webm',
  'dog_video_file_1.webm',
  'fox_video_file_1.webm',
];

// Let's create a video pre-cache and store all first segments of videos inside.
window.caches.open('video-pre-cache')
.then(cache => Promise.all(videoFileUrls.map(videoFileUrl => fetchAndCache(videoFileUrl, cache))));

function fetchAndCache(videoFileUrl, cache) {
  // Check first if video is in the cache.
  return cache.match(videoFileUrl)
  .then(cacheResponse => {
    // Let's return cached response if video is already in the cache.
    if (cacheResponse) {
      return cacheResponse;
    }
    // Otherwise, fetch the video from the network.
    return fetch(videoFileUrl)
    .then(networkResponse => {
      // Add the response to the cache and return network response in parallel.
      cache.put(videoFileUrl, networkResponse.clone());
      return networkResponse;
    });
  });
}

שימו לב שאם אשתמש בבקשות Range מסוג HTTP, אצטרך ליצור מחדש באופן ידני אובייקט Response, כי עדיין ממשק ה-API של המטמון לא תומך ב-Range תגובות. שימו לב שקריאה ל-networkResponse.arrayBuffer() מאחזרת את כל התוכן של התגובה בבת אחת לזיכרון של כלי הרינדור, ולכן כדאי להשתמש בטווחים קטנים.

לידיעתך, שיניתי חלק מהדוגמה למעלה כדי לשמור בקשות מסוג HTTP Range במטמון מראש של הסרטון.

    ...
    return fetch(videoFileUrl, { headers: { range: 'bytes=0-567139' } })
    .then(networkResponse => networkResponse.arrayBuffer())
    .then(data => {
      const response = new Response(data);
      // Add the response to the cache and return network response in parallel.
      cache.put(videoFileUrl, response.clone());
      return response;
    });

לצפייה בסרטון

כשמשתמש ילחץ על לחצן הפעלה, נאחזר את הקטע הראשון של הסרטון שזמין בממשק ה-API של המטמון, כדי שההפעלה תתחיל באופן מיידי, אם היא זמינה. אחרת, פשוט נאחזר אותו מהרשת. חשוב לזכור שדפדפנים ומשתמשים יכולים להחליט למחוק את המטמון.

כמו שראינו בעבר, אנחנו משתמשים ב-MSE כדי להזין את הקטע הראשון של הסרטון לרכיב הווידאו.

function onPlayButtonClick(videoFileUrl) {
  video.load(); // Used to be able to play video later.

  window.caches.open('video-pre-cache')
  .then(cache => fetchAndCache(videoFileUrl, cache)) // Defined above.
  .then(response => response.arrayBuffer())
  .then(data => {
    const mediaSource = new MediaSource();
    video.src = URL.createObjectURL(mediaSource);
    mediaSource.addEventListener('sourceopen', sourceOpen, { once: true });

    function sourceOpen() {
      URL.revokeObjectURL(video.src);

      const sourceBuffer = mediaSource.addSourceBuffer('video/webm; codecs="vp09.00.10.08"');
      sourceBuffer.appendBuffer(data);

      video.play().then(() => {
        // TODO: Fetch the rest of the video when user starts playing video.
      });
    }
  });
}

יצירת תגובות לטווח באמצעות קובץ שירות (service worker)

מה קורה אם מאחזרים קובץ סרטון שלם ושומרים אותו ב-API למטמון? כשהדפדפן שולח בקשת Range מסוג HTTP, אתם בטח לא רוצים להעביר את כל הסרטון לזיכרון הרינדור, מכיוון ש-API למטמון לא תומך עדיין בתגובות של Range.

בואו נראה איך ליירט את הבקשות האלה ולהחזיר תגובה Range מותאמת אישית מ-Service Worker.

addEventListener('fetch', event => {
  event.respondWith(loadFromCacheOrFetch(event.request));
});

function loadFromCacheOrFetch(request) {
  // Search through all available caches for this request.
  return caches.match(request)
  .then(response => {

    // Fetch from network if it's not already in the cache.
    if (!response) {
      return fetch(request);
      // Note that we may want to add the response to the cache and return
      // network response in parallel as well.
    }

    // Browser sends a HTTP Range request. Let's provide one reconstructed
    // manually from the cache.
    if (request.headers.has('range')) {
      return response.blob()
      .then(data => {

        // Get start position from Range request header.
        const pos = Number(/^bytes\=(\d+)\-/g.exec(request.headers.get('range'))[1]);
        const options = {
          status: 206,
          statusText: 'Partial Content',
          headers: response.headers
        }
        const slicedResponse = new Response(data.slice(pos), options);
        slicedResponse.setHeaders('Content-Range': 'bytes ' + pos + '-' +
            (data.size - 1) + '/' + data.size);
        slicedResponse.setHeaders('X-From-Cache': 'true');

        return slicedResponse;
      });
    }

    return response;
  }
}

חשוב לציין שהשתמשתי ב-response.blob() כדי ליצור מחדש את התשובה הפרוסה, כי זה פשוט נותן לי כינוי לקובץ בזמן ש-response.arrayBuffer() מעביר את הקובץ כולו לזיכרון של הרינדור.

ניתן להשתמש בכותרת ה-HTTP המותאמת אישית X-From-Cache כדי לדעת אם הבקשה הגיעה מהמטמון או מהרשת. שחקן כמו ShakaPlayer יכול להשתמש בו כדי להתעלם מזמן התגובה כמדד למהירות הרשת.

כדי לקבל פתרון מלא לטיפול בבקשות Range, הציצו ב-Sample Media App הרשמי ובמיוחד את הקובץ ranged-response.js.