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. Show 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ốngCá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 Ư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:
Bây giờ hãy để HLS tỏa sáng nào 🥇HLS StreamingHLS 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:
Ưu điểm của HLS là:
Nhưng không phải là không có nhược điểm:
Xong HLS rồi, giờ thì tìm hiểu về Ffmpeg nhé. 🥇FfmpegFFmpeg 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 FfmpegThườ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 (
)} 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.jsLờ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 (
)} 1 trong module import { exec } from 'child_process' export const getBitrate = (filePath: string) => { return (
)} 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 (
)} À, 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ượngCâ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:
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 (
)} 6, import { exec } from 'child_process' export const getBitrate = (filePath: string) => { return (
)} 7, import { exec } from 'child_process' export const getBitrate = (filePath: string) => { return (
)} 8,... và một file import { exec } from 'child_process' export const getBitrate = (filePath: string) => { return (
)} 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ảiConvert 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ảiCâ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 videoChấ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). 🥈BitrateBitrate (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ácNgoà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 HLSDướ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
🚨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
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 $ return stdout.trim() === 'audio' } const getBitrate = async (filePath: string) => { const { $ } = await import('zx') const slash = (await import('slash')).default const { stdout } = await $ return Number(stdout.trim()) } const getResolution = async (filePath: string) => { const { $ } = await import('zx') const slash = (await import('slash')).default const { stdout } = await $ const resolution = stdout.trim().split('x') const [width, height] = resolution return {
}} 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: {
}outputSegmentPath: string outputPath: string bitrate: {
}} const encodeMax720 = async ({ bitrate, inputPath, isHasAudio, outputPath, outputSegmentPath, resolution }: EncodeByResolution) => { const { $ } = await import('zx') const slash = (await import('slash')).default const args = [
]if (isHasAudio) {
}args.push(
)if (isHasAudio) {
} else {
}args.push(
) await $ 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) {
} else {
}args.push(
)if (isHasAudio) {
} else {
}args.push(
) await $ 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) {
} else {
}args.push(
)if (isHasAudio) {
} else {
}args.push(
) await $ 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) {
} else {
}args.push(
)if (isHasAudio) {
} else {
}args.push(
) await $ 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) {
}if (resolution.height > 1080) {
}if (resolution.height > 1440) {
}await encodeFunc({
})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ếtNó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é. |