采用 React 前端的 Node.js HLS 服务器,用于自适应比特率流式传输

什么是 HLS?

HTTP Live Streaming(HLS)是苹果公司开发的一种协议,用于在互联网上无缝传输音频和视频内容。它的工作原理是将内容分解成小的、可管理的片段,并通过 HTTP 将这些片段提供给观众。HLS 的主要优势之一是在台式机、移动设备、智能电视等各种设备和平台之间具有广泛的兼容性。HLS 以小块的形式传输视频,可以实现高效的流媒体传输,减少缓冲的可能性,提供更稳定的观看体验。这种方法还能使浏览视频变得更容易,因为每个片段都能单独获取,从而加快了搜索或暂停等操作的响应时间。

自适应比特率流

自适应比特率流(ABR)是流媒体中使用的一种技术,可根据观众当前的网络条件动态调整视频流的质量。这样,即使网络带宽波动,视频播放也能保持流畅和不间断。通过提供不同质量水平的多个视频版本,流媒体服务器可以在这些版本之间实时切换,选择网络在任何特定时刻都能支持的最高质量。这种方法可优化观看体验,减少缓冲并提高整体视频质量。

自适应比特率流(ABR)是 HLS 的核心功能。在播放过程中,HLS 播放器会监控观看者的网络状况,并在这些片段之间切换,以提供尽可能最佳的质量,而不会出现中断。

在实际应用中实施 HLS 和 ABR

本文将进一步介绍如何设置 HLS 流媒体服务器以及用于视频上传和流媒体的前端界面。

  • 后端由 Node.js 和 Express 构建,负责管理视频处理和转换为 HLS 格式。
  • 通过集成多功能多媒体框架 FFmpeg,后端可处理视频。
  • 前端使用 React 和 Vite 开发,为用户提供上传和流式传输视频的界面。

前提条件

在继续之前,请确保您的系统已安装必要的工具:用于Web应用程序的 Node.js 和用于视频处理的 FFmpeg。

设置后端服务器

为应用程序创建一个文件夹。

mkdir hls-streaming-app 
cd hls-streaming-app

初始化一个新的 Node.js 项目。

npm init -y

安装必要的依赖项:

npm install express cors multer uuid
npm install concurrently nodemon --save-dev

现在,在根文件夹中创建一个名为 index.js 的文件,就可以开始编码了。

后端配置步骤

打开 index.js,导入所需软件包。

import express from "express";
import cors from "cors";
import multer from "multer";
import { v4 as uuidv4 } from "uuid";
import path from "path";
import fs from 'fs';
import { exec } from "child_process";

接下来,设置 Express 应用程序并添加所需的中间件。这包括提供静态文件、解析 JSON 请求和处理 CORS。开发时,将 CORS 起源设置为 *,但生产部署时要记得更新,以限制对可信来源的访问。此外,在应用程序目录下创建一个 uploads 文件夹。

const app = express();
app.use(cors({origin: ['*']}));

app.use((req, res, next)=>{
    res.header('Access-Control-Allow-Origin', "*");
    res.header("Acess-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept")
    next();
});

app.use(express.json());
app.use(express.urlencoded({extended: true}));
app.use("/uploads", express.static("uploads")); // serve static files
  • express.static 用于提供 uploads 目录中的静态文件。
  • express.json() express.urlencoded() 分别用于解析带有 JSON 有效载荷和 URL 编码数据的传入请求。
  • cors() 用于处理跨源资源共享(Cross-Origin Resource Sharing),允许前台与后台通信。

Multer 是一个处理 multipart/form-data 的中间件,主要用于上传文件。下面介绍如何配置 Multer:

// Multer configuration
const storage = multer.diskStorage({
    destination: function(req, file, cb) {
        cb(null, './uploads');
    },
    filename: function(req, file, cb) {
        cb(null, file.fieldname + '-' + uuidv4() + path.extname(file.originalname));
    }
});

// Multer middleware
const upload = multer({ storage: storage });
  • multer.diskStorage() 会指定上传文件的目标目录,并使用字段名称、唯一 UUID 和原始文件扩展名的组合定义自定义文件名格式。
  • upload 常量已通过此存储设置进行了配置,可在路由中用作中间件来处理文件上传。

设置一个路由来处理视频上传,并使用 FFmpeg 将上传的视频转换为具有多种质量级别的 HLS 格式。在此之前,请在根文件夹中创建一个新文件 playlist.m3u8,并粘贴以下内容。

playlist.m3u8 文件作为主 HLS 文件,包含对不同质量级别的单个媒体播放列表的引用,从而实现自适应比特率流。

#EXTM3U
#EXT-X-VERSION:3
#EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360
360p.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=1400000,RESOLUTION=842x480
480p.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2800000,RESOLUTION=1280x720
720p.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=5000000,RESOLUTION=1920x1080
1080p.m3u8

创建一个 POST 路由,使用 Multer 管理文件上传。上传后,执行 FFmpeg 将视频转换为各种 HLS 流,每种流都是根据 360p、480p、720p 和 1080p 等分辨率定制的。对缩放比例、比特率和分段文件名进行相应的自定义设置。FFmpeg 完成后,将根文件夹中的 playlist.m3u8 复制到 HLS 输出文件夹。在发送响应之前,确保存储新上传视频的 URL。虽然我在这里使用的是文本文件存储,但建议使用数据库以提高可扩展性。

const linkFilePath = './videoLinks.txt';

const storelink = (videoLink) => {
    const newLinkLine = `${videoLink}\n`
    fs.appendFileSync(linkFilePath, newLinkLine, 'utf-8');
    console.log('[INFO] video URL stored successfully.')
}


app.post("/upload", upload.single('file'), (req, res)=>{
    console.log("[INFO] File uploaded.");

    const videoId = uuidv4();
    const videoPath = req.file.path;
    const outputPath = `./uploads/hls-videos/${videoId}`
    const hlsPath = `${outputPath}/playlist.m3u8` // HLS is a unstiched video file and index.m3u8 work as index for video chunks

    if(!fs.existsSync(outputPath)){
        fs.mkdirSync(outputPath, {recursive: true})
    }
   
    const ffmpegCommand = `ffmpeg -hide_banner -y -i ${videoPath} \
    -vf scale=w=640:h=360:force_original_aspect_ratio=decrease -c:a aac -ar 48000 -c:v h264 -profile:v main -crf 20 -sc_threshold 0 -g 48 -keyint_min 48 -hls_time 4 -hls_playlist_type vod  -b:v 800k -maxrate 856k -bufsize 1200k -b:a 96k -hls_segment_filename ${outputPath}/360p_%03d.ts ${outputPath}/360p.m3u8 \
    -vf scale=w=842:h=480:force_original_aspect_ratio=decrease -c:a aac -ar 48000 -c:v h264 -profile:v main -crf 20 -sc_threshold 0 -g 48 -keyint_min 48 -hls_time 4 -hls_playlist_type vod -b:v 1400k -maxrate 1498k -bufsize 2100k -b:a 128k -hls_segment_filename ${outputPath}/480p_%03d.ts ${outputPath}/480p.m3u8 \
    -vf scale=w=1280:h=720:force_original_aspect_ratio=decrease -c:a aac -ar 48000 -c:v h264 -profile:v main -crf 20 -sc_threshold 0 -g 48 -keyint_min 48 -hls_time 4 -hls_playlist_type vod -b:v 2800k -maxrate 2996k -bufsize 4200k -b:a 128k -hls_segment_filename ${outputPath}/720p_%03d.ts ${outputPath}/720p.m3u8 \
    -vf scale=w=1920:h=1080:force_original_aspect_ratio=decrease -c:a aac -ar 48000 -c:v h264 -profile:v main -crf 20 -sc_threshold 0 -g 48 -keyint_min 48 -hls_time 4 -hls_playlist_type vod -b:v 5000k -maxrate 5350k -bufsize 7500k -b:a 192k -hls_segment_filename ${outputPath}/1080p_%03d.ts ${outputPath}/1080p.m3u8`

    // This is converter code and can be design in distributed way
    exec(ffmpegCommand, (error, stdout, stderr) =>{
        if(error){
            console.error(`[ERROR] exec error: ${error}`)
            res.json({"error": "Error while processing your file. Please try again."});
        }

        console.log(`stdout: ${stdout}`);
        console.log(`stderr: ${stderr}`);

        fs.copyFileSync('./playlist.m3u8', `${outputPath}/playlist.m3u8`);

        const videoUrl = `http://localhost:8000/uploads/hls-videos/${videoId}/playlist.m3u8`

        try{
            storelink(videoUrl);
        } catch(error){
            console.error(`[ERROR] error while storing video URL: ${error}`);
            res.json({"error": "Error while processing your file. Please try again."})
        }
        res.json({"message": "File uploaded successfully.", videoUrl: videoUrl, videoId: videoId})
    })
});

此路由中的 ffmpeg 命令可将上传的视频转换为多种质量级别的 HLS 格式。以下是该命令的详细说明:

  • hide_banner -y -i ${videoPath}: 隐藏横幅并覆盖输出文件,无需询问。输入视频文件路径由 ${videoPath} 指定。
  • vf scale=w=640:h=360:force_original_aspect_ratio=decrease:将视频缩放至 360p 分辨率,同时保持宽高比。
  • c:a aac -ar 48000 -c:v h264 -profile:v main -crf 20:指定音频和视频编解码器、采样率、配置文件和质量设置。
  • sc_threshold 0 -g 48 -keyint_min 48:配置场景变化阈值、图片组(GOP)大小和最小关键帧间隔。
  • hls_time 4 -hls_playlist_type vod:将 HLS 片段持续时间设置为 4 秒,并指定播放列表类型为视频点播 (VOD)。
  • b:v 800k -maxrate 856k -bufsize 1200k -b:a 96k:为 360p 质量设置视频和音频比特率、最大比特率和缓冲区大小。
  • hls_segment_filename ${outputPath}/360p_%03d.ts ${outputPath}/360p.m3u8:为 360p 画质定义片段文件名模式和播放列表文件名。

对于 480p、720p 和 1080p 质量级别重复此模式,每个质量级别都有自己的缩放、比特率和分段文件名设置。这将为自适应比特率流创建多个 HLS 流。

此外,请考虑尝试使用这些命令的不同值来微调视频转换过程。

我们还需要一种路由来检索所有可用视频的 URL。

const readLinks = () => {
    const data = fs.readFileSync(linkFilePath, 'utf-8');
    const links = data.trim().split('\n');
    return links;
}

// Route to fetch all available videos
app.get("/videos", (req, res) => {
    res.json({
        "videoUrls": readLinks()
    });
});

最后,配置 express 应用程序侦听端口(在这里,我使用的是 8000),以接收接收到的请求。

// Set up port listener
app.listen(8000, () => {
    console.log("[INFO] App is running at port 8000");
});

package.json 中修改脚本如下:

"scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "start:backend": "nodemon index.js",
  }

现在,在 shell 中运行 npm run start:backend 来启动后端服务器。使用 Postman 测试路由,确保收到成功的响应。如果遇到任何问题,请参阅我在 https://github.com/ameya-shahu/HLS-Streaming-Server-React 上发布的完整 index.js 代码。

按如下方式配置 postman 请求,并选择任何视频文件。

采用 React 前端的 Node.js HLS 服务器,用于自适应比特率流式传输

现在我们已经完成了后端服务器,让我们使用 Vite 来设置应用程序的前端。

使用 Vite 初始化 React 应用程序

要开始使用 Vite 设置前端,请按照以下步骤操作:

  • 打开根文件夹下的终端,运行以下命令创建一个新的 Vite 项目:
npm create vite @latest frontend -- --template React
  • 使用 cd frontend 将目录更改为 frontend,然后执行命令 npm install 安装依赖项。
  • 运行以下命令安装其他依赖项,如从后台获取数据的 axios 和作为视频播放器的 video.js。
npm install bootstrap react-bootstrap axios video.js@7.21 videojs-contrib-quality-levels videojs-http-source-selector

安装完依赖项后,使用 npm run dev 启动开发服务器,并导航至 http://localhost:5173/ 以确保一切运行正常。

创建视频播放器组件

从 Video.js 文档中引用了视频播放器的大部分代码。自定义视频播放器的关键在于代码中定义的 videoPlayerOptions。在 /frontend/src/ 目录下创建一个名为 VideoPlayer.jsx 的新文件,并复制以下代码:

import React, { useRef, useEffect } from "react";
import videojs from "video.js";
import "video.js/dist/video-js.css";
import "videojs-contrib-quality-levels";
import "videojs-http-source-selector";

export const VideoPlayer = (props) => {
  const videoRef = useRef(null);
  const playerRef = useRef(null);
  const { videoSource } = props;

  const videoPlayerOptions = {
    fluid: true,
    controls: true,
    playbackRates: [0.5, 1, 1.5, 2],
    controlBar: {
        playToggle: true,
        volumePanel: {
            inline: false
        },
        fullscreenToggle: true
        },
        plugins: {
          httpSourceSelector: { default: 'auto' }
        },
    sources: [
      {
        src: videoSource,
        type: "application/x-mpegURL"
      }
    ]
  }

  const handlePlayerReady = (player) => {
    playerRef.current = player;

    // You can handle player events here, for example:
    player.on("waiting", () => {
      videojs.log("player is waiting");
    });

    player.on("dispose", () => {
      videojs.log("player will dispose");
    });
  };

  useEffect(() => {
    // Make sure Video.js player is only initialized once
    if (!playerRef.current) {
      // The Video.js player needs to be _inside_ the component el for React 18 Strict Mode.
      const videoElement = document.createElement("video-js");

      videoElement.classList.add("vjs-big-play-centered");
      videoRef.current.appendChild(videoElement);

      const player = (playerRef.current = videojs(videoElement, videoPlayerOptions, () => {
        videojs.log("player is ready");
        handlePlayerReady && handlePlayerReady(player);
      }));

      // You could update an existing player in the `else` block here
      // on prop change, for example:
    } else {
      const player = playerRef.current;

      player.autoplay(videoPlayerOptions.autoplay);
      player.src(videoPlayerOptions.sources);
    }
  }, [videoPlayerOptions, videoRef]);

  // Dispose the Video.js player when the functional component unmounts
  useEffect(() => {
    const player = playerRef.current;

    return () => {
      if (player && !player.isDisposed()) {
        player.dispose();
        playerRef.current = null;
      }
    };
  }, [playerRef]);

  return (
    <div
      data-vjs-player
    >
      <div ref={videoRef} style={{ width: '100%' }}/>
    </div>
  );
};

export default VideoPlayer;

创建视频文件上传组件

在 /frontend/src/ 中创建 VideoUploadForm.jsx 并复制以下代码:

import React, { useState, useRef  } from 'react';
import Form from 'react-bootstrap/Form';
import Button from 'react-bootstrap/Button';

const VideoUploadForm = () => {
    const [file, setFile] = useState(null);
    const [isUploading, setIsUploading] = useState(false);
    const fileRef = useRef();

    const handleReset = () => {
        fileRef.current.value = null;
    };
    
    const uploadFile = async (file) => {
      const formData = new FormData();
      formData.append('file', file);
      try{
          const response = await axios.post("http://localhost:8000/upload", formData, {
              headers: {
                  'Content-Type': 'multipart/form-data',
              }
          })
          if (response.status === 200) {
              alert('File uploaded successfully!');
            } else {
              alert('File upload failed!');
          } 
  
      } catch(error){
          console.error('Error uploading video file: ', error);
          alert('File upload failed!');
      }
    }

    const validateFileType = (file) => {
        const allowedExtensions = ["mp4", "mkv"];
        const fileNameParts = file.name.split(".");
        const fileExtension = fileNameParts[fileNameParts.length - 1].toLowerCase();
    
        return allowedExtensions.includes(fileExtension);
    }

    const handleFileChange = (event) => {
        const selectedFile = event.target.files?.[0];
  
        if (selectedFile) {
          if (validateFileType(selectedFile)){
              setFile(selectedFile);
          }else{
              setFile(null);
              event.target.value = '';
              alert("Only mp4 and mkv is allowed.");
          }      
        }
    };

    const handleSubmit = async (event) =>{
        event.preventDefault();
        setIsUploading(true);
    
        if (file) {
            const uploadFileResult = await uploadFile(file);
            handleReset();
            setFile(null);
            setIsUploading(false);
            window.location.reload();
        }else{
          alert("Select a valid video file.")
          setIsUploading(false);
        }
      }

  return (
    <Form onSubmit={handleSubmit}>
      <Form.Group controlId="formFile" className="mb-3">
        <Form.Label>
          <h5 className="text-center">Upload your video file with maximum quality. (Recommended 1080p)</h5>
        </Form.Label>
        <div className="d-flex align-items-center">
          <Form.Control type="file" className="me-3" ref={fileRef} onChange={handleFileChange}/>
          <Button variant="primary" type="submit" className="ms-auto" disabled = {isUploading}>
            {isUploading ? "Uploading.." : "Submit"}
          </Button>
        </div>
      </Form.Group>
    </Form>
  )
}

export default VideoUploadForm

修改 App.jsx

下面是修改后的 App.jsx,其中集成了 VideoUploadForm 和 VideoPlayer 组件:

import React, { useState, useEffect } from 'react';
import axios from 'axios';
import VideoPlayer from './VideoPlayer';
import VideoUpload from './VideoUploadForm';
import 'bootstrap/dist/css/bootstrap.min.css';

const App = () => {
  const [videoSources, setVideoSources] = useState([]);

  useEffect(() =>{
      fetchVideoSources();
  }, [])

  const getVideosSourceList = async (file) => {
      try{
          const response = await axios.get("http://localhost:8000/videos")
  
          if (response.status === 200) {
              return response.data.videoUrls;
            } else {
              return [];
          } 
  
      } catch(error){
          console.error('Error uploading video file: ', error);
          return [];
      }
  }

  const fetchVideoSources = async() =>{
      const response = await getVideosSourceList();
      setVideoSources(response);
  }
  
  return (
    <Container className='mt-5'>
      <Row className="justify-content-md-center">
          <Col xs lg="6"><VideoUploadForm /></Col>
      </Row>
      <Row>
         <Col><hr></hr></Col>
       </Row>
      {
        videoSources.reduce((result, value, index) => {
            (index % 2 === 0) ? result.push([value]) : result[result.length - 1].push(value);
            return result;
            }, [])
            .map((item, index)=>{
            return(
                <Row className='justify-content-md-center' key={index}>
                    {
                        item.map((val, idx)=>{
                            return(
                                <Col key={idx} xs={6} className='p-3 border justify-content-md-center'>
                                    <VideoPlayer videoSource={val} />
                                </Col>
                            )
                        })
                    }
                </Row>
            )
            })
         }
      </Container>
    )
}

export default App

后台和前台组件都已就位,现在就可以测试应用程序了。在根文件夹中修改 package.json 中的 “脚本 “如下:

"scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "start:backend": "nodemon index.js",
    "start:frontend": "npm run dev --prefix frontend",
    "dev": "concurrently \"npm run start:frontend\" \"npm run start:backend\""
  }

要同时运行后端和前端服务器,请在根文件夹中运行 npm run dev。

打开浏览器并导航到应用程序。使用 “选择文件 “按钮选择视频,然后点击 “提交”。上传完成后,视频将列在应用程序中,随时可以进行流式传输。

要测试自适应比特率流媒体,可以模拟不同的网络条件。打开 Google Chrome 浏览器的 “开发工具”,转到 “网络 “选项卡,然后使用 “节流 “下拉菜单选择不同的网络速度(如 3G、4G)。这将有助于你观察视频播放器如何根据可用带宽调整视频质量。确保将视频质量设置为自动,以便播放器在不同质量级别之间动态切换。

采用 React 前端的 Node.js HLS 服务器,用于自适应比特率流式传输
动态质量适应:从 1080p 到 360p

测试期间,视频最初以 1080p 分辨率流畅播放。在模拟缓慢的 3G 网络条件时,播放器将分辨率调整为 360p,以确保在带宽降低的情况下仍能不间断地播放。这种动态调整凸显了自适应码率流在不同网速下保持一致观看体验的功效。这种无缝过渡突显了应用程序适应不断变化的网络条件的能力,从而确保最佳的视频质量和播放效果。

作者:Ameya Shahu

本文来自作者投稿,版权归原作者所有。如需转载,请注明出处:https://www.nxrte.com/jishu/48554.html

(0)

相关推荐

发表回复

登录后才能评论