Hướng dẫn dựng server để stream video năm 2024

Bạn tự hỏi họ dùng công nghệ gì mà có thể phát video mượt mà, có nhiều option độ phân giải cho người dùng lựa chọn.

Bí quyết nằm ở giao thức Adaptive Streaming. Công nghệ này cho phép server phát video với nhiều độ phân giải khác nhau, bitrate khác nhau. Khi người dùng xem video, server sẽ tự động chọn độ phân giải, bitrate phù hợp với tốc độ mạng của người dùng.

Có 2 giao thức Adaptive Streaming phổ biến nhất hiện nay là HLS và DASH. HLS thì phổ biến trong hệ sinh thái Apple.

Bài này chúng ta sẽ tìm hiểu về HLS nhen.

Nhưng trước hết thì chúng ta tìm hiểu về cách thức stream video truyền thống trước nhé.

Stream video là phát video từ server cho người dùng xem. Live stream là phát video trực tiếp. Ở đây chúng ta sẽ bàn về cách phát lại video đã được upload lên server.

🥇Stream video truyền thống

Cách stream video đơn giản nhất là upload video như thế nào thì phát lại cho người dùng y như thế.

Ví dụ: Upload 1 video phim nặng 10 GB, độ phân giải 2k, file .mp4, bitrate 10 Mbps lên server. Server sẽ phát lại video này cho người dùng với đầy đủ thông số kể trên.

Ưu điểm của cách này là đơn giản, đem lại chất lượng video nguyên bản cho người xem. Nếu bạn dùng Express.js bên Node.js thì chỉ cần khai báo 1 route như thế này là có thể stream video được rôi:

ts

app.use('/static/video', express.static(UPLOAD_VIDEO_DIR))

Nhưng cách này tồn tại rất nhiều nhược điểm:

  • Trình duyệt có thể không play được video vì không hỗ trợ định dạng video đó, ví dụ định dạng ffmpeg -i input.mp4 output.avi 0 chẳng hạng.
  • Vì video nguyên bản, nên dung lượng sẽ rất là lớn, nếu người dùng có mạng yếu thì sẽ bị lag, giật.
  • Lúc nào cũng stream video với độ phân giải cao thì sẽ gây tốn băng thông cho người xem lẫn server

Bây giờ hãy để HLS tỏa sáng nào


🥇HLS Streaming

HLS là viết tắt của HTTP Live Streaming. Đây là một giao thức Adaptive Streaming do Apple phát triển.

Flow hoạt động cũng không có gì phức tạp:

  1. Upload 1 video lên server thì server sẽ tiến hành convert video thành file ffmpeg -i input.mp4 output.avi 1 và nhiều file ffmpeg -i input.mp4 output.avi 2 nhỏ. Mỗi file ffmpeg -i input.mp4 output.avi 2 sẽ chứa từng phân đoạn của video. Công cụ thường dùng để convert là Ffmpeg.
  2. Khi người dùng xem video, server sẽ phát video bằng cách gửi các file ffmpeg -i input.mp4 output.avi 2 này cho trình duyệt. Ở phía trình duyệt sẽ dùng các thư viện hỗ trợ hls như ffmpeg -i input.mp4 output.avi 5 để phát video.
    Hướng dẫn dựng server để stream video năm 2024
    Chúng ta có thể tạo nhiều file .ts với nhiều độ phân giải khác nhau

Ưu điểm của HLS là:

  • Hỗ trợ nhiều định dạng video, audio khác nhau.
  • Hỗ trợ nhiều chất lượng video khác nhau.
  • Tự động chọn chất lượng video phù hợp với tốc độ mạng của người dùng.
  • Tốn ít băng thông hơn so với stream video truyền thống.

Nhưng không phải là không có nhược điểm:

  • Tích hợp phức tạp hơn so với stream video truyền thống.
  • Cần phải convert video => tốn nhiều thời gian
  • Tốn nhiều bộ nhớ hơn so với stream video truyền thống. Vì bây giờ chúng ta phải lưu nhiều chất lượng video khác nhau 🥲. Mà cái này cũng tùy nha, bác nào chỉ lưu 1 định dạng thôi thì có thể nó sẽ tiết kiệm bộ nhớ hơn đó.

Xong HLS rồi, giờ thì tìm hiểu về Ffmpeg nhé.


🥇Ffmpeg

FFmpeg là một công cụ mã nguồn mở, hỗ trợ convert video, audio. Công cụ này có thể chạy trên nhiều nền tảng khác nhau như Windows, Linux, MacOS.

Chúng ta sẽ dùng terminal để chạy Ffmpeg, ví dụ:

bash

ffmpeg -i input.mp4 output.avi

Lệnh trên sẽ convert file

ffmpeg -i input.mp4 output.avi

6 thành file

ffmpeg -i input.mp4 output.avi

7.

🥈Cài đặt Ffmpeg

Thường thì server chúng ta sẽ chạy trên Linux, nhưng để test và dev trên máy local thì chúng ta phải tìm cách cài đặt trên hệ điều hành của chúng ta.

Đây là trang download chính chủ của Ffmpeg: https://ffmpeg.org/download.html

Nhưng mình khuyến khích mọi người search youtube để xem cách cài đặt cho nhanh nhé.

Từ khóa:

ffmpeg -i input.mp4 output.avi

8,

ffmpeg -i input.mp4 output.avi

9,

import { exec } from 'child_process'

export const getBitrate = (filePath: string) => {

return (

new Promise() <
number >
((resolve, reject) => {
  exec(
    `ffprobe -v error -select_streams v:0 -show_entries stream=bit_rate -of default=nw=1:nk=1 ${filePath}`,
    (err, stdout, stderr) => {
      if (err) {
        return reject(err)
      }
      resolve(Number(stdout.trim()))
    }
  )
})
)

}

0

Ví dụ đây là cách cài đặt trên Mac Apple Silicon:

Sau khi cài đặt xong rồi mọi người gõ thử câu lệnh phía dưới xem, nếu nó hiển thị version thì là cài đặt thành công rồi nhé.

🥈Tích hợp FFmpeg vào Node.js

Lời khuyên của mình dành cho các bạn là không nên dùng những thư viện bên thứ 3 để tích hợp Ffmpeg vào Node.js hay bất kỳ ngôn ngữ server như Java, Go, Rust,...

Vì FFmpeg nó được build cho hệ điều hành chứ không phải là dành cho một ngôn ngữ lập trình nào cả. Nên việc bạn tìm kiếm những lib ngoài để tích hợp vào server sẽ có một số rủi ro như: Ít option để lựa chọn, dễ gặp lỗi, lib không được cập nhật thường xuyên,...

Ví dụ Node.js có thư viện node-fluent-ffmpeg, đã 6 năm không cập nhật gì mới, và còn dùng ffmpeg version 2.

Ơ thế thì dùng như thế nào? Làm sao để tương tác với terminal nhỉ?

Điều này không hề khó, các ngôn ngữ backend hiện nay đều có thể tương tác với terminal một cách rất dễ dàng. Với nodejs thì chúng ta sẽ dùng

import { exec } from 'child_process'

export const getBitrate = (filePath: string) => {

return (

new Promise() <
number >
((resolve, reject) => {
  exec(
    `ffprobe -v error -select_streams v:0 -show_entries stream=bit_rate -of default=nw=1:nk=1 ${filePath}`,
    (err, stdout, stderr) => {
      if (err) {
        return reject(err)
      }
      resolve(Number(stdout.trim()))
    }
  )
})
)

}

1 trong module

import { exec } from 'child_process'

export const getBitrate = (filePath: string) => {

return (

new Promise() <
number >
((resolve, reject) => {
  exec(
    `ffprobe -v error -select_streams v:0 -show_entries stream=bit_rate -of default=nw=1:nk=1 ${filePath}`,
    (err, stdout, stderr) => {
      if (err) {
        return reject(err)
      }
      resolve(Number(stdout.trim()))
    }
  )
})
)

}

2 để tương tác với terminal.

Ví dụ đoạn code dưới đây mình sẽ dùng ffmpeg để lấy bitrate của video

js

import { exec } from 'child_process'

export const getBitrate = (filePath: string) => {

return (

new Promise() <
number >
((resolve, reject) => {
  exec(
    `ffprobe -v error -select_streams v:0 -show_entries stream=bit_rate -of default=nw=1:nk=1 ${filePath}`,
    (err, stdout, stderr) => {
      if (err) {
        return reject(err)
      }
      resolve(Number(stdout.trim()))
    }
  )
})
)

}

À, dạo này có một cái lib là ffmpeg.wasm, đọc sơ qua cũng khá uy tín, nó dùng wasm để chạy FFmpeg trên môi trường trình duyệt thôi, các phiên bản trước đây thì chạy trên node.js được nhưng giờ thì không nữa. Về performance thì chậm hơn 10 lần so với dùng cách thuần bằng ffmpeg của mình (mình đọc doc của họ là thế).

Oke, bây giờ thì làm sao để biết mấy câu lệnh ffmpeg để convert video, audio nhỉ?

Nếu bạn muốn nghiên cứu sâu vào FFmpeg thì đọc document của họ 😂

Còn mình thì lười hơn, và mình muốn nhanh nên dùng ChatGPT để tìm và giải thích script, cùng với đó là research trên mấy cái blog như hlsbook và H.264 Video Encoding Guide.

Sau quãng thời gian vọc vạch thì mình đúc kết được một số script ffmpeg phổ biến, hay dùng, các bạn có thể tham khảo nhé.

🥈Các câu lệnh FFmpeg phổ biến

Để giải thích các câu lệnh FFmpeg dưới đây, các bạn cứ paste nó vào ChatGPT là được nhé.

🥉Convert video sang HLS giữ nguyên chất lượng

Câu lệnh dưới đây sẽ giữ nguyên chất lượng video lẫn audio của video đó. Chỉ đơn thuần là convert sang định dạng HLS:

  • import { exec } from 'child_process' export const getBitrate = (filePath: string) => { return (
    new Promise() <  
    number >  
    ((resolve, reject) => {  
      exec(  
        ffprobe -v error -select_streams v:0 -show_entries stream=bit_rate -of default=nw=1:nk=1 ${filePath},  
        (err, stdout, stderr) => {  
          if (err) {  
            return reject(err)  
          }  
          resolve(Number(stdout.trim()))  
        }  
      )  
    })  
    
    ) } 3 là đường dẫn video input và import { exec } from 'child_process' export const getBitrate = (filePath: string) => { return (
    new Promise() <  
    number >  
    ((resolve, reject) => {  
      exec(  
        ffprobe -v error -select_streams v:0 -show_entries stream=bit_rate -of default=nw=1:nk=1 ${filePath},  
        (err, stdout, stderr) => {  
          if (err) {  
            return reject(err)  
          }  
          resolve(Number(stdout.trim()))  
        }  
      )  
    })  
    
    ) } 4 là đường dẫn output
  • import { exec } from 'child_process' export const getBitrate = (filePath: string) => { return (
    new Promise() <  
    number >  
    ((resolve, reject) => {  
      exec(  
        ffprobe -v error -select_streams v:0 -show_entries stream=bit_rate -of default=nw=1:nk=1 ${filePath},  
        (err, stdout, stderr) => {  
          if (err) {  
            return reject(err)  
          }  
          resolve(Number(stdout.trim()))  
        }  
      )  
    })  
    
    ) } 5 là thời gian mỗi segment video là 10s

bash

ffmpeg -i input.mov -codec: copy -start_number 0 -hls_time 10 -hls_list_size 0 -f hls input.m3u8

Câu lệnh trên sẽ tạo ra các file dạng

import { exec } from 'child_process'

export const getBitrate = (filePath: string) => {

return (

new Promise() <
number >
((resolve, reject) => {
  exec(
    `ffprobe -v error -select_streams v:0 -show_entries stream=bit_rate -of default=nw=1:nk=1 ${filePath}`,
    (err, stdout, stderr) => {
      if (err) {
        return reject(err)
      }
      resolve(Number(stdout.trim()))
    }
  )
})
)

}

6,

import { exec } from 'child_process'

export const getBitrate = (filePath: string) => {

return (

new Promise() <
number >
((resolve, reject) => {
  exec(
    `ffprobe -v error -select_streams v:0 -show_entries stream=bit_rate -of default=nw=1:nk=1 ${filePath}`,
    (err, stdout, stderr) => {
      if (err) {
        return reject(err)
      }
      resolve(Number(stdout.trim()))
    }
  )
})
)

}

7,

import { exec } from 'child_process'

export const getBitrate = (filePath: string) => {

return (

new Promise() <
number >
((resolve, reject) => {
  exec(
    `ffprobe -v error -select_streams v:0 -show_entries stream=bit_rate -of default=nw=1:nk=1 ${filePath}`,
    (err, stdout, stderr) => {
      if (err) {
        return reject(err)
      }
      resolve(Number(stdout.trim()))
    }
  )
})
)

}

8,... và một file

import { exec } from 'child_process'

export const getBitrate = (filePath: string) => {

return (

new Promise() <
number >
((resolve, reject) => {
  exec(
    `ffprobe -v error -select_streams v:0 -show_entries stream=bit_rate -of default=nw=1:nk=1 ${filePath}`,
    (err, stdout, stderr) => {
      if (err) {
        return reject(err)
      }
      resolve(Number(stdout.trim()))
    }
  )
})
)

}

4 để chứa thông tin về các file

ffmpeg -i input.mov -codec: copy -start_number 0 -hls_time 10 -hls_list_size 0 -f hls input.m3u8

0 đó.

🥉Convert video sang HLS theo độ phân giải

Convert sang HLS và scale down video về một kích thước nào đó, ví dụ 320x180, thì bạn có thể dùng câu lệnh sau:

bash

ffmpeg -i input.mov -vf scale=320:180 -start_number 0 -hls_time 10 -hls_list_size 0 -f hls input.m3u8

Câu lệnh trên mình không chọn codec nên nó sẽ được FFmpeg tự động chọn codec phù hợp nhất cho video đó.

Câu lệnh dưới đây mình sẽ chọn codec là H.264 và audio là AAC, cùng với đó là scale video về 1280x720, đổi tên file segment thành

ffmpeg -i input.mov -codec: copy -start_number 0 -hls_time 10 -hls_list_size 0 -f hls input.m3u8

1,

ffmpeg -i input.mov -codec: copy -start_number 0 -hls_time 10 -hls_list_size 0 -f hls input.m3u8

2,... và đổi tên file playlist thành

ffmpeg -i input.mov -codec: copy -start_number 0 -hls_time 10 -hls_list_size 0 -f hls input.m3u8

3

bash

ffmpeg -i input.mov \

-c:v libx264 -s 1280x720 \

-c:a aac \

-hls_time 10 -hls_list_size 0 -hls_segment_filename "segment_%03d.ts" \

-f hls output.m3u8

Đôi lúc chúng ta muốn chuyển về độ phân giải thấp hơn, nhưng vẫn giữ nguyên tỉ lệ khung hình, thì chúng ta có thể dùng câu lệnh sau:

bash

ffmpeg -i input.mov \

-c:v libx264 -vf "scale=-1:720" \

-c:a aac \

-hls_time 10 -hls_list_size 0 -hls_segment_filename "segment_%03d.ts" \

-f hls output.m3u8

Câu lệnh trên mình dùng

ffmpeg -i input.mov -codec: copy -start_number 0 -hls_time 10 -hls_list_size 0 -f hls input.m3u8

4 để nó tự động tính toán ra chiều rộng của video, sao cho tỉ lệ khung hình vẫn giữ nguyên, và chiều cao là 720px.

🥉Convert video sang HLS theo nhiều độ phân giải

Câu lệnh dưới mình convert 2 độ phân giải là 640x360 và 960x540.

Các bạn lưu ý là khi convert video sang nhiều độ phân giải thì nên có file

ffmpeg -i input.mov -codec: copy -start_number 0 -hls_time 10 -hls_list_size 0 -f hls input.m3u8

5 để chứa thông tin về các file playlist của các độ phân giải đó.

bash

ffmpeg -y -i input.mp4 \

-preset slow -g 48 -sc_threshold 0 \

-map 0:0 -map 0:1 -map 0:0 -map 0:1 \

-s:v:0 640x360 -c:v:0 libx264 -b:v:0 365k \

-s:v:1 960x540 -c:v:1 libx264 -b:v:1 2000k \

-c:a copy \

-var_stream_map "v:0,a:0 v:1,a:1" \

-master_pl_name master.m3u8 \

-f hls -hls_time 6 -hls_list_size 0 \

-hls_segment_filename "v%v/fileSequence%d.ts" \

v%v/prog_index.m3u8

Câu lệnh này sẽ tạo ra file playlist

ffmpeg -i input.mov -codec: copy -start_number 0 -hls_time 10 -hls_list_size 0 -f hls input.m3u8

5 cùng với đó là

ffmpeg -i input.mov -codec: copy -start_number 0 -hls_time 10 -hls_list_size 0 -f hls input.m3u8

7,

ffmpeg -i input.mov -codec: copy -start_number 0 -hls_time 10 -hls_list_size 0 -f hls input.m3u8

8,

ffmpeg -i input.mov -codec: copy -start_number 0 -hls_time 10 -hls_list_size 0 -f hls input.m3u8

9,... và

ffmpeg -i input.mov -vf scale=320:180 -start_number 0 -hls_time 10 -hls_list_size 0 -f hls input.m3u8

0,

ffmpeg -i input.mov -vf scale=320:180 -start_number 0 -hls_time 10 -hls_list_size 0 -f hls input.m3u8

1,

ffmpeg -i input.mov -vf scale=320:180 -start_number 0 -hls_time 10 -hls_list_size 0 -f hls input.m3u8

2,...

Khuyết điểm của câu lệnh trên là không thể chọn tỉ lệ khung hình cho video, mình đang cố định độ phân giải, vậy nên nếu video đầu vào không đúng tỉ lệ khi convert sang nó sẽ bị lệch.

Vậy có cách fix không?

Có, Chúng ta sẽ kết hợp chạy các câu lệnh ffmpeg trong nodejs để tự sinh ra command thích hợp. Chi tiết các bạn kéo xuống phần code demo nodejs phía dưới sẽ thấy!


🥇Lưu ý về chất lượng video

Chất lượng một video sau khi convert sẽ bị ảnh hưởng bởi vài yếu tố như:

🥈Thuật toán nén video (Video codec)

Có một vài thuật toán nén video phổ biến như: H.264, H.265, VP9, AV1,...

H.265 sẽ đem lại hiệu suất nén tốt hơn H.264, nghĩa là cùng 1 size video thì H.265 sẽ đem lại chất lượng video cao hơn. Nhưng điểm yếu của H.265 là nó sẽ tốn nhiều CPU hơn để encode video, cũng như là nó không được hỗ trợ trên một số thiết bị cũ.

Phổ biến nhất hiện nay vẫn là H.264, nên mình sẽ dùng H.264.

Khi chọn thuật toán nén thì cũng nên lưu ý định dạng video đầu ra, ví dụ bạn muốn output là video mp4 thì nên dùng H.264, còn nếu là webm thì nên dùng VP9 (H.264 không hỗ trợ Webm).

🥈Bitrate

Bitrate (tỷ lệ bit) là một đơn vị đo lường tốc độ truyền dữ liệu, thường được sử dụng để mô tả tốc độ truyền dữ liệu của âm thanh, video hoặc dữ liệu mạng.

Bitrate được đo bằng bit trên giây (bps) hoặc các đơn vị phổ biến khác như kilobit trên giây (Kbps), megabit trên giây (Mbps) hoặc gigabit trên giây (Gbps).

Kiểu như trong 1s thì video đó chứa bao nhiêu bit vậy đó, càng nhiều thì càng nét. Nhưng bù lại thì càng nhiều thì càng tốn dung lượng.

Lưu ý là không phải lúc nào bitrate càng cao thì video càng nét nhé, nó có giới hạn của nó.

Ví dụ: Video gốc của mình có bitrate là 10,44 Mbit/s và độ phân giải là 2880 x 1800. Bây giờ mình tăng bitrate lên 20 Mbit/s thì video sẽ không nét hơn được 😂

🥈Độ phân giải

Độ phân giải là một đơn vị đo lường kích thước của một hình ảnh, video hoặc màn hình. Nó được đo bằng số lượng pixel trên mỗi chiều của hình ảnh hoặc màn hình.

Video có độ phân giải càng cao thì càng nét, nhưng cũng càng tốn dung lượng.

🥈Các yếu tố khác

Ngoài các yếu tố kể trên thì chất lượng video đầu ra còn phụ thuộc vào một số thứ như: Chất lượng audio, FPS,...

Nhưng mà đa số các trường hợp thì chúng ta chỉ cần quan tâm các yếu tố kể trên là đủ rồi.


🥇Demo code Node.js convert video sang HLS

Dưới đây là đoạn code typescript sẽ convert video sang HLS với chất lượng tùy vào chất lượng video đầu vào

  • Mặc định sẽ convert sang 720p
  • Từ 720p -> 1080p sẽ convert sang 720p và 1080p
  • Từ 1080p -> 1440p sẽ convert sang 720p, 1080p và 1440p
  • Từ 1440p trở lên sẽ convert sang 720p, 1080p, maximum

🚨Trước khi đi vào đoạn code thì mình sẽ nói một số lưu ý về đoạn code sau

  • Đoạn code này được viết bằng TypeScript, bạn nào dùng JavaScript chịu khó convert sang nhé.
  • Mình dùng thư viện ffmpeg -i input.mov -vf scale=320:180 -start_number 0 -hls_time 10 -hls_list_size 0 -f hls input.m3u8 3 để chạy command cho dễ và dùng ffmpeg -i input.mov -vf scale=320:180 -start_number 0 -hls_time 10 -hls_list_size 0 -f hls input.m3u8 4 để chuyển các path về dạng unix như ubuntu (mục đích là giúp window chạy không có lỗi 😂), vậy nên trước khi chạy đoạn code này, bạn phải chạy ffmpeg -i input.mov -vf scale=320:180 -start_number 0 -hls_time 10 -hls_list_size 0 -f hls input.m3u8 5 để cài đặt nó.
  • ffmpeg -i input.mov -vf scale=320:180 -start_number 0 -hls_time 10 -hls_list_size 0 -f hls input.m3u8 3 sẽ chạy các câu lệnh ffmpeg, vậy nên yêu cầu máy của bạn phải cài đặt ffmpeg từ trước.
  • Vì thư viện ffmpeg -i input.mov -vf scale=320:180 -start_number 0 -hls_time 10 -hls_list_size 0 -f hls input.m3u8 3 và ffmpeg -i input.mov -vf scale=320:180 -start_number 0 -hls_time 10 -hls_list_size 0 -f hls input.m3u8 4 này được đóng gói theo ES Module, nên mình mới dùng cú pháp ffmpeg -i input.mov -vf scale=320:180 -start_number 0 -hls_time 10 -hls_list_size 0 -f hls input.m3u8 9 và ffmpeg -i input.mov \ -c:v libx264 -s 1280x720 \ -c:a aac \ -hls_time 10 -hls_list_size 0 -hls_segment_filename "segment_%03d.ts" \ -f hls output.m3u8 0 để sử dụng trên dự án chạy ffmpeg -i input.mov \ -c:v libx264 -s 1280x720 \ -c:a aac \ -hls_time 10 -hls_list_size 0 -hls_segment_filename "segment_%03d.ts" \ -f hls output.m3u8 1. Nếu dự án bạn dùng ES Module thì cứ import bình thường.
  • Mình đã test trên Mac OS, Linux để chạy được nhé. Riêng Windows thì các bạn dùng Bash terminal (cái terminal khi cài git nó có á) để chạy Node.js, còn PowerShell hay CMD thì có thể gặp lỗi với thư viện ffmpeg -i input.mov -vf scale=320:180 -start_number 0 -hls_time 10 -hls_list_size 0 -f hls input.m3u8 3 này.

Cách dùng thì cứ gọi

ffmpeg -i input.mov \

-c:v libx264 -s 1280x720 \

-c:a aac \

-hls_time 10 -hls_list_size 0 -hls_segment_filename "segment_%03d.ts" \

-f hls output.m3u8

3

ts

import path from 'path'

const MAXIMUM_BITRATE_720P = 5 10 * 6

const MAXIMUM_BITRATE_1080P = 8 10 * 6

const MAXIMUM_BITRATE_1440P = 16 10 * 6

export const checkVideoHasAudio = async (filePath: string) => {

const { $ } = await import('zx')

const slash = (await import('slash')).default

const { stdout } = await $ffprobe ${[

'-v',
'error',
'-select_streams',
'a:0',
'-show_entries',
'stream=codec_type',
'-of',
'default=nw=1:nk=1',
slash(filePath)
]}

return stdout.trim() === 'audio'

}

const getBitrate = async (filePath: string) => {

const { $ } = await import('zx')

const slash = (await import('slash')).default

const { stdout } = await $ffprobe ${[

'-v',
'error',
'-select_streams',
'v:0',
'-show_entries',
'stream=bit_rate',
'-of',
'default=nw=1:nk=1',
slash(filePath)
]}

return Number(stdout.trim())

}

const getResolution = async (filePath: string) => {

const { $ } = await import('zx')

const slash = (await import('slash')).default

const { stdout } = await $ffprobe ${[

'-v',
'error',
'-select_streams',
'v:0',
'-show_entries',
'stream=width,height',
'-of',
'csv=s=x:p=0',
slash(filePath)
]}

const resolution = stdout.trim().split('x')

const [width, height] = resolution

return {

width: Number(width),
height: Number(height)
}

}

const getWidth = (height: number, resolution: { width: number; height: number }) => {

const width = Math.round((height * resolution.width) / resolution.height)

return width % 2 === 0 ? width : width + 1

}

type EncodeByResolution = {

inputPath: string

isHasAudio: boolean

resolution: {

width: number
height: number
}

outputSegmentPath: string

outputPath: string

bitrate: {

720: number
1080: number
1440: number
original: number
}

}

const encodeMax720 = async ({

bitrate,

inputPath,

isHasAudio,

outputPath,

outputSegmentPath,

resolution

}: EncodeByResolution) => {

const { $ } = await import('zx')

const slash = (await import('slash')).default

const args = [

'-y',
'-i',
slash(inputPath),
'-preset',
'veryslow',
'-g',
'48',
'-crf',
'17',
'-sc_threshold',
'0',
'-map',
'0:0'
]

if (isHasAudio) {

args.push('-map', '0:1')
}

args.push(

'-s:v:0',
`${getWidth(720, resolution)}x720`,
'-c:v:0',
'libx264',
'-b:v:0',
`${bitrate[720]}`,
'-c:a',
'copy',
'-var_stream_map'
)

if (isHasAudio) {

args.push('v:0,a:0')
} else {
args.push('v:0')
}

args.push(

'-master_pl_name',
'master.m3u8',
'-f',
'hls',
'-hls_time',
'6',
'-hls_list_size',
'0',
'-hls_segment_filename',
slash(outputSegmentPath),
slash(outputPath)
)

await $ffmpeg ${args}

return true

}

const encodeMax1080 = async ({

bitrate,

inputPath,

isHasAudio,

outputPath,

outputSegmentPath,

resolution

}: EncodeByResolution) => {

const { $ } = await import('zx')

const slash = (await import('slash')).default

const args = ['-y', '-i', slash(inputPath), '-preset', 'veryslow', '-g', '48', '-crf', '17', '-sc_threshold', '0']

if (isHasAudio) {

args.push('-map', '0:0', '-map', '0:1', '-map', '0:0', '-map', '0:1')
} else {
args.push('-map', '0:0', '-map', '0:0')
}

args.push(

'-s:v:0',
`${getWidth(720, resolution)}x720`,
'-c:v:0',
'libx264',
'-b:v:0',
`${bitrate[720]}`,
'-s:v:1',
`${getWidth(1080, resolution)}x1080`,
'-c:v:1',
'libx264',
'-b:v:1',
`${bitrate[1080]}`,
'-c:a',
'copy',
'-var_stream_map'
)

if (isHasAudio) {

args.push('v:0,a:0 v:1,a:1')
} else {
args.push('v:0 v:1')
}

args.push(

'-master_pl_name',
'master.m3u8',
'-f',
'hls',
'-hls_time',
'6',
'-hls_list_size',
'0',
'-hls_segment_filename',
slash(outputSegmentPath),
slash(outputPath)
)

await $ffmpeg ${args}

return true

}

const encodeMax1440 = async ({

bitrate,

inputPath,

isHasAudio,

outputPath,

outputSegmentPath,

resolution

}: EncodeByResolution) => {

const { $ } = await import('zx')

const slash = (await import('slash')).default

const args = ['-y', '-i', slash(inputPath), '-preset', 'veryslow', '-g', '48', '-crf', '17', '-sc_threshold', '0']

if (isHasAudio) {

args.push('-map', '0:0', '-map', '0:1', '-map', '0:0', '-map', '0:1', '-map', '0:0', '-map', '0:1')
} else {
args.push('-map', '0:0', '-map', '0:0', '-map', '0:0')
}

args.push(

'-s:v:0',
`${getWidth(720, resolution)}x720`,
'-c:v:0',
'libx264',
'-b:v:0',
`${bitrate[720]}`,
'-s:v:1',
`${getWidth(1080, resolution)}x1080`,
'-c:v:1',
'libx264',
'-b:v:1',
`${bitrate[1080]}`,
'-s:v:2',
`${getWidth(1440, resolution)}x1440`,
'-c:v:2',
'libx264',
'-b:v:2',
`${bitrate[1440]}`,
'-c:a',
'copy',
'-var_stream_map'
)

if (isHasAudio) {

args.push('v:0,a:0 v:1,a:1 v:2,a:2')
} else {
args.push('v:0 v:1 v2')
}

args.push(

'-master_pl_name',
'master.m3u8',
'-f',
'hls',
'-hls_time',
'6',
'-hls_list_size',
'0',
'-hls_segment_filename',
slash(outputSegmentPath),
slash(outputPath)
)

await $ffmpeg ${args}

return true

}

const encodeMaxOriginal = async ({

bitrate,

inputPath,

isHasAudio,

outputPath,

outputSegmentPath,

resolution

}: EncodeByResolution) => {

const { $ } = await import('zx')

const slash = (await import('slash')).default

const args = ['-y', '-i', slash(inputPath), '-preset', 'veryslow', '-g', '48', '-crf', '17', '-sc_threshold', '0']

if (isHasAudio) {

args.push('-map', '0:0', '-map', '0:1', '-map', '0:0', '-map', '0:1', '-map', '0:0', '-map', '0:1')
} else {
args.push('-map', '0:0', '-map', '0:0', '-map', '0:0')
}

args.push(

'-s:v:0',
`${getWidth(720, resolution)}x720`,
'-c:v:0',
'libx264',
'-b:v:0',
`${bitrate[720]}`,
'-s:v:1',
`${getWidth(1080, resolution)}x1080`,
'-c:v:1',
'libx264',
'-b:v:1',
`${bitrate[1080]}`,
'-s:v:2',
`${resolution.width}x${resolution.height}`,
'-c:v:2',
'libx264',
'-b:v:2',
`${bitrate.original}`,
'-c:a',
'copy',
'-var_stream_map'
)

if (isHasAudio) {

args.push('v:0,a:0 v:1,a:1 v:2,a:2')
} else {
args.push('v:0 v:1 v2')
}

args.push(

'-master_pl_name',
'master.m3u8',
'-f',
'hls',
'-hls_time',
'6',
'-hls_list_size',
'0',
'-hls_segment_filename',
slash(outputSegmentPath),
slash(outputPath)
)

await $ffmpeg ${args}

return true

}

export const encodeHLSWithMultipleVideoStreams = async (inputPath: string) => {

const [bitrate, resolution] = await Promise.all([getBitrate(inputPath), getResolution(inputPath)])

const parent_folder = path.join(inputPath, '..')

const outputSegmentPath = path.join(parent_folder, 'v%v/fileSequence%d.ts')

const outputPath = path.join(parent_folder, 'v%v/prog_index.m3u8')

const bitrate720 = bitrate > MAXIMUM_BITRATE_720P ? MAXIMUM_BITRATE_720P : bitrate

const bitrate1080 = bitrate > MAXIMUM_BITRATE_1080P ? MAXIMUM_BITRATE_1080P : bitrate

const bitrate1440 = bitrate > MAXIMUM_BITRATE_1440P ? MAXIMUM_BITRATE_1440P : bitrate

const isHasAudio = await checkVideoHasAudio(inputPath)

let encodeFunc = encodeMax720

if (resolution.height > 720) {

encodeFunc = encodeMax1080
}

if (resolution.height > 1080) {

encodeFunc = encodeMax1440
}

if (resolution.height > 1440) {

encodeFunc = encodeMaxOriginal
}

await encodeFunc({

bitrate: {
  720: bitrate720,
  1080: bitrate1080,
  1440: bitrate1440,
  original: bitrate
},
inputPath,
isHasAudio,
outputPath,
outputSegmentPath,
resolution
})

return true

}

Đoạn code trên mới convert sang HLS, còn việc stream thì đơn giản chỉ là serving static file

ffmpeg -i input.mp4 output.avi

1 và

ffmpeg -i input.mp4 output.avi

2 thôi chứ không có gì đặc biệt đâu, y như các bạn serving image vậy.

Còn dưới client thì dùng mấy trình play video hỗ trợ HLS như


🥇Tổng kết

Nói chung thì HLS stream có rất nhiều cái lợi, chỉ có khuyết điểm là nó convert hơi lâu và tốn khá nhiều CPU nên anh em cần phải xử lý thêm chỗ này nữa, không là treo server đó nhé.