Потокове передавання відеофайлу на відеоплеєр html5 за допомогою Node.js, щоб елементи керування відео продовжували працювати?


96

Tl; Dr - Питання:

Яким є правильний спосіб обробки потокового відеофайлу на відеопрогравач html5 за допомогою Node.js, щоб елементи керування відео продовжували працювати?

Я думаю, це пов’язано з тим, як обробляються заголовки. У будь-якому випадку, ось довідкова інформація. Код трохи довгий, однак, він досить простий.

Потокове передавання невеликих відеофайлів на відео HTML5 за допомогою Node легко

Я навчився дуже легко передавати невеликі відеофайли у відеоплеєр HTML5. При такому налаштуванні елементи управління працюють без жодної роботи з мого боку, а відеопотік ідеально. Тут можна завантажити робочу копію повністю працюючого коду із зразком відео для завантаження з Google Docs .

Клієнт:

<html>
  <title>Welcome</title>
    <body>
      <video controls>
        <source src="movie.mp4" type="video/mp4"/>
        <source src="movie.webm" type="video/webm"/>
        <source src="movie.ogg" type="video/ogg"/>
        <!-- fallback -->
        Your browser does not support the <code>video</code> element.
    </video>
  </body>
</html>

Сервер:

// Declare Vars & Read Files

var fs = require('fs'),
    http = require('http'),
    url = require('url'),
    path = require('path');
var movie_webm, movie_mp4, movie_ogg;
// ... [snip] ... (Read index page)
fs.readFile(path.resolve(__dirname,"movie.mp4"), function (err, data) {
    if (err) {
        throw err;
    }
    movie_mp4 = data;
});
// ... [snip] ... (Read two other formats for the video)

// Serve & Stream Video

http.createServer(function (req, res) {
    // ... [snip] ... (Serve client files)
    var total;
    if (reqResource == "/movie.mp4") {
        total = movie_mp4.length;
    }
    // ... [snip] ... handle two other formats for the video
    var range = req.headers.range;
    var positions = range.replace(/bytes=/, "").split("-");
    var start = parseInt(positions[0], 10);
    var end = positions[1] ? parseInt(positions[1], 10) : total - 1;
    var chunksize = (end - start) + 1;
    if (reqResource == "/movie.mp4") {
        res.writeHead(206, {
            "Content-Range": "bytes " + start + "-" + end + "/" + total,
                "Accept-Ranges": "bytes",
                "Content-Length": chunksize,
                "Content-Type": "video/mp4"
        });
        res.end(movie_mp4.slice(start, end + 1), "binary");
    }
    // ... [snip] ... handle two other formats for the video
}).listen(8888);

Але цей метод обмежений файлами розміром менше 1 Гб.

Потокове передавання (будь-якого розміру) відеофайлів за допомогою fs.createReadStream

Використовуючи fs.createReadStream(), сервер може читати файл у потоці, а не читати його все в пам’ять одночасно. Це звучить як правильний спосіб робити щось, а синтаксис надзвичайно простий:

Фрагмент сервера:

movieStream = fs.createReadStream(pathToFile);
movieStream.on('open', function () {
    res.writeHead(206, {
        "Content-Range": "bytes " + start + "-" + end + "/" + total,
            "Accept-Ranges": "bytes",
            "Content-Length": chunksize,
            "Content-Type": "video/mp4"
    });
    // This just pipes the read stream to the response object (which goes 
    //to the client)
    movieStream.pipe(res);
});

movieStream.on('error', function (err) {
    res.end(err);
});

Це потокове відео просто чудово! Але елементи керування відео більше не працюють.


1
Я залишив цей writeHead()код прокоментованим, але там на випадок, якщо це допоможе. Чи слід видаляти це, щоб зробити фрагмент коду більш читабельним?
WebDeveloper404

3
звідки береться req.headers.range? Я постійно отримую невизначеність, коли намагаюся виконати метод заміни. Дякую.
Чад Воткінс,

Відповіді:


118

Для роботи елементів керування відео HTML5 потрібен Accept Rangesзаголовок (біт writeHead()).

Я думаю, що замість просто сліпого надсилання повного файлу спочатку слід перевірити Accept Rangesзаголовок у ЗАПИТІ, потім прочитати та надіслати саме цей біт. fs.createReadStreamпідтримка start, і endваріант для цього.

Тож я спробував приклад, і він працює. Код не гарний, але його легко зрозуміти. Спочатку ми обробляємо заголовок діапазону, щоб отримати початкове / кінцеве положення. Потім ми використовуємо, fs.statщоб отримати розмір файлу, не зчитуючи весь файл у пам’ять. Нарешті, використовуйте fs.createReadStreamдля надсилання запитуваної деталі клієнту.

var fs = require("fs"),
    http = require("http"),
    url = require("url"),
    path = require("path");

http.createServer(function (req, res) {
  if (req.url != "/movie.mp4") {
    res.writeHead(200, { "Content-Type": "text/html" });
    res.end('<video src="http://localhost:8888/movie.mp4" controls></video>');
  } else {
    var file = path.resolve(__dirname,"movie.mp4");
    fs.stat(file, function(err, stats) {
      if (err) {
        if (err.code === 'ENOENT') {
          // 404 Error if file not found
          return res.sendStatus(404);
        }
      res.end(err);
      }
      var range = req.headers.range;
      if (!range) {
       // 416 Wrong range
       return res.sendStatus(416);
      }
      var positions = range.replace(/bytes=/, "").split("-");
      var start = parseInt(positions[0], 10);
      var total = stats.size;
      var end = positions[1] ? parseInt(positions[1], 10) : total - 1;
      var chunksize = (end - start) + 1;

      res.writeHead(206, {
        "Content-Range": "bytes " + start + "-" + end + "/" + total,
        "Accept-Ranges": "bytes",
        "Content-Length": chunksize,
        "Content-Type": "video/mp4"
      });

      var stream = fs.createReadStream(file, { start: start, end: end })
        .on("open", function() {
          stream.pipe(res);
        }).on("error", function(err) {
          res.end(err);
        });
    });
  }
}).listen(8888);

3
Чи можемо ми використати цю стратегію для надсилання лише деякої частини фільму, тобто між 5-ю секунду та 7-ю секунду? Чи можна знайти, що цей інтервал відповідає інтервалу байтів за допомогою ffmpeg, як бібліотеки? Дякую.
pembeci

8
Не зважайте на моє запитання. Я знайшов чарівні слова, щоб знайти, як досягти того, про що я просив: псевдопотокове передавання .
pembeci

Як це може змусити працювати, якщо з якихось причин movie.mp4 знаходиться у зашифрованому форматі, і нам потрібно його розшифрувати перед потоковою передачею у браузер?
saraf

@saraf: Це залежить від того, який алгоритм використовується для шифрування. Це працює з потоком або працює лише як шифрування цілого файлу? Чи можливо, що ви просто розшифруєте відео у тимчасове місце і подасте як зазвичай? Взагалі кажучи, це можливо, але це може бути складно. Тут немає загального рішення.
tungd

Привіт, tungd, дякую за відповідь! випадком використання є пристрій на основі Raspberry Pi, який буде діяти як платформа розповсюдження медіа для розробників навчального контенту. Ми можемо вільно вибирати алгоритм шифрування, ключ буде в мікропрограмі - але пам’ять обмежена 1 ГБ оперативної пам’яті, а розмір вмісту становить близько 200 ГБ (що буде на знімному носії - підключений USB.) Навіть щось на зразок алгоритму Clear Key з EME було б нормально - за винятком проблеми, що хром не має вбудованої EME на ARM. Тільки того, що самого знімного носія інформації не повинно бути достатньо для відтворення / копіювання.
saraf

24

Прийнята відповідь на це питання є чудовою і повинна залишатися прийнятою відповіддю. Однак я зіткнувся з проблемою з кодом, де потік читання не завжди закінчувався / закривався. Частиною рішення було надіслати autoClose: trueразом з start:start, end:endдругим createReadStreamаргументом.

Інша частина рішення полягала в обмеженні максимальної кількості, chunksizeщо надсилається у відповіді. Інша відповідь встановлена endприблизно так:

var end = positions[1] ? parseInt(positions[1], 10) : total - 1;

... що має наслідком відправлення решти файлу із запитуваної початкової позиції через останній байт, незалежно від того, скільки байт це може бути. Однак клієнтський браузер має можливість прочитати лише частину цього потоку і буде, якщо йому ще не потрібні всі байти. Це призведе до того, що прочитаний потік буде заблокований, поки браузер не вирішить, що настав час отримати більше даних (наприклад, дія користувача, як пошук / скраб, або просто відтворення потоку).

Мені потрібно було закрити цей потік, оскільки я відображав <video>елемент на сторінці, яка дозволяла користувачеві видалити відеофайл. Однак файл не видалявся з файлової системи, поки клієнт (або сервер) не закрив з'єднання, оскільки лише так потік закінчувався / закривався.

Моє рішення полягало лише в тому, щоб встановити maxChunkзмінну конфігурації, встановити її в 1 Мб, і ніколи не передавати потік читання більше 1 Мб одночасно до відповіді.

// same code as accepted answer
var end = positions[1] ? parseInt(positions[1], 10) : total - 1;
var chunksize = (end - start) + 1;

// poor hack to send smaller chunks to the browser
var maxChunk = 1024 * 1024; // 1MB at a time
if (chunksize > maxChunk) {
  end = start + maxChunk - 1;
  chunksize = (end - start) + 1;
}

Це має наслідком переконатися, що потік читання закінчується / закривається після кожного запиту та не залишається в живих браузером.

Я також написав окреме запитання та відповідь щодо StackOverflow, що висвітлюють це питання.


Це чудово працює для Chrome, але, схоже, не працює в Safari. У сафарі це спрацьовує лише тоді, коли воно може запросити весь асортимент. Ви робите щось інше для Safari?
f1lt3r

2
При подальшому копанні: Safari бачить "/ $ {total}" у 2-байтовій відповіді, а потім каже ... "Гей, як ти просто надіслав мені цілий файл?". Потім, коли йому кажуть: "Ні, ви отримуєте лише перші 1 Мб!", Safari засмучується "Сталася помилка під час спроби керувати ресурсом".
f1lt3r

0

Спочатку створіть app.jsфайл у каталозі, який ви хочете опублікувати.

var http = require('http');
var fs = require('fs');
var mime = require('mime');
http.createServer(function(req,res){
    if (req.url != '/app.js') {
    var url = __dirname + req.url;
        fs.stat(url,function(err,stat){
            if (err) {
            res.writeHead(404,{'Content-Type':'text/html'});
            res.end('Your requested URI('+req.url+') wasn\'t found on our server');
            } else {
            var type = mime.getType(url);
            var fileSize = stat.size;
            var range = req.headers.range;
                if (range) {
                    var parts = range.replace(/bytes=/, "").split("-");
                var start = parseInt(parts[0], 10);
                    var end = parts[1] ? parseInt(parts[1], 10) : fileSize-1;
                    var chunksize = (end-start)+1;
                    var file = fs.createReadStream(url, {start, end});
                    var head = {
                'Content-Range': `bytes ${start}-${end}/${fileSize}`,
                'Accept-Ranges': 'bytes',
                'Content-Length': chunksize,
                'Content-Type': type
                }
                    res.writeHead(206, head);
                    file.pipe(res);
                    } else {    
                    var head = {
                'Content-Length': fileSize,
                'Content-Type': type
                    }
                res.writeHead(200, head);
                fs.createReadStream(url).pipe(res);
                    }
            }
        });
    } else {
    res.writeHead(403,{'Content-Type':'text/html'});
    res.end('Sorry, access to that file is Forbidden');
    }
}).listen(8080);

Просто запустіть, node app.jsі ваш сервер повинен працювати на порту 8080. Крім відео, він може передавати всі види файлів.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.