如何使用 Node.js、FFmpeg 和 Next.js 构建全栈视频流平台:综合指南

创建视频流平台是一个令人兴奋而又充满挑战的项目。最近,我将 Node.js 与用于后端处理的 FFmpeg 和用于现代前端的 Next.js 整合在一起,开始了这一旅程。在本文中,我将向您介绍完整的设置,包括视频处理、前端集成以及我如何解决沿途的各种问题。

使用的技术

1. Node.js 和 Express:

  • 目的:处理视频上传、转换为 HLS 格式并提供视频文件。
  • 主要特点:快速、可扩展、无阻塞 I/O。

2. FFmpeg:

  • 目的:将视频文件转换为HLS(HTTP Live Streaming)格式。
  • 主要功能:处理多媒体数据的开源工具。它可以转码、转换和流式传输音频和视频。
  • HLS 和 M3U8:
  • HLS:一种通过互联网传输视频的协议。它将视频分成小片段并以播放列表的形式提供。
  • M3U8:HLS 用于组织视频片段和元数据的播放列表文件格式。

3. Multer:

  • 目的:处理multipart/form-data中间件,用于上传文件。
  • 主要特点:轻松处理文件上传,包括设置文件大小限制和唯一命名文件。

4. Next.js:

  • 目的:提供具有服务器端渲染、静态站点生成和 API 路由的现代前端框架。
  • 主要特点:高效路由、内置 CSS 支持和轻松的 API 集成。

后端设置

1. 快速服务器设置:

  • 端点:处理视频上传,使用 FFmpeg 将视频转换为 HLS 格式,并提供视频文件。
  • 代码重点
  • 使用Multer上传视频。
  • 使用FFmpeg将视频转换为 HLS 格式。
  • 提供.m3u8播放列表和.ts片段。

Server.ts:

import express from "express";
import cors from "cors";
import httpErrors from "http-errors";
import path from "path";
import fs from "fs";
import { exec } from "child_process";

interface CustomError extends Error {
    code?:string
}

const app = express();

app.use(
   cors({
    origin: ["http://localhost:3000", "http://localhost:8000"],
    credentials: true,
})
);

app.options("*", cors());

// Serve static files with proper mime types
app.use(
  "uploads",
  express.static("uploads", {
    setHeaders: (res, filePath) =>{
        if(filePath.endsWith(".m3u8")){
        res.setHeader("Content-Type", "application/vnd.apple.mpegurl");
        }else if(filePath.endsWith(".ts")) {
            res.setHeader("Content-Type", "video/mp2t");
        }else if(filePath.endsWith(".mov")) {
            res.setHeader("Content-Type", "video/quicktime");
        }
    },
 })
);

app.use(express.json());
app.use(express.urlencoded({ extended: true }));

const storage = multer.diskStorage({
    destination: function(req, file, cb) {
        const dirname = path.join(__dirname, "uploads");
        fs.mkdirSync(dirname, { recursive: true });
        cb(null, dirname);
    },
    filename: function(req, file, cb) {
        cb(null, uuidv4() + path.extname(file.originalname));
    },
});

const upload = multer({
    storage: storage,
    limits: { fileSize: 100 * 1024 * 1024 },
});

app.get("/upload", function(req, res) {
    const courseDirectoryPath = path.resolve(__dirname, "uploads/course");
    fs.readdir(courseDirectoryPath, (err, files) => {
        if(err) return res.status(500).json({ message: "Unable to scan directory", error: err });

        const lessonIds = files.filter(file => !file.startsWith('.'));
        if(lessonIds.length === 0) {
            return res.status(404).json({ message: "No lessons found" });
        }

        const urls = lessonIds.map((lessonId) => 
            uploads/course/${lessonId}/index.m3u8
        );

        res.status(200).json(urls);
    });
});

app.post("/upload", upload.single("file"), function(req, res) {
    if(!req.file) {
        return res.status(400).json({ message: "No file uploaded" });
    }

    const lessonId = uuidv4();
    const videoPath = path.join(__dirname, req.file.path);
    const outputPath = path.join(__dirname, "output");

    console.log(`hls-path: /upload/course/${lessonId}/index.m3u8`);

    if(fs.existsSync(outputPath)) {
        fs.mkdirSync(outputPath, { recursive: true });
    }

    // ffmpeg command to copy video to HLS format
    const ffmpegCommand = `-i ${videoPath} -codec:v libx264 -crf 24 \
-map 0 -flags +global_header -segment_format mpegts \
-output_path ${outputPath}/segment%03d.ts -start_number 0${hlsPath}`;

    exec(ffmpegCommand, (error, stdout, stderr) => {
        if(error) {
            console.error('exec error: ${error}');
            return res.status(500).json({ message: "Error converting video" });
        }

        console.log(stdout);
        console.log(stderr);

        const videoUrl = `${http://${process.env.HOST || 'localhost'}:${process.env.PORT || 3000}/uploads/course/${lessonId}/index.m3u8`;
        res.json({
            videoUrl: videoUrl,
            lessonId: lessonId,
        });
    });
});

app.get("/", function(req, res) {
    res.json({ message: "Hello World!" });
});

let PORT = process.env.PORT || 3000;

const server = app.listen(PORT, function() {
    console.log(`Server running on port ${PORT}`);
});

server.on('error', function(err) {
    if(err.code === 'EADDRINUSE') {
        console.log(`Port is already in use. Switching to a different port...`);
        PORT = Number(PORT) + 1;
        app.listen(PORT, () => {
            console.log(`Server running on port ${PORT}`);
        });
    } else {
        console.error(`Server error: ${err as any}.message`);
    }
});

2. FFmpeg 用于 HLS 转换的命令:

ffmpeg -i "${videoPath}" -codec:v libx264 -codec:a aac -hls_time 10 -hls_playlist_type vod -hls_segment_filename "${outputPath}/segment%03d.ts" -start_number 0 "${hlsPath}"

前端集成

1. 视频播放器组件:

  • 使用 Hls.js:一个 JavaScript 库,可在支持 HLS 的浏览器中启用 HLS 播放。
  • 实现:加载 HLS 源并将其附加到视频元素以实现流畅播放。

hlsPlayer.tsx:

'use client'

import React, { useRef, useEffect } from 'react'
import Hls from 'hls.js'

interface VideoPlayerProps {
  src: string;
  poster?: string;
}

export function VideoPlayer({ src, poster }: VideoPlayerProps) {
  const videoRef = useRef<HTMLVideoElement>(null)

  useEffect(() => {
    let hls: Hls | null = null

    if (videoRef.current) {
      const video = videoRef.current
      
      if (Hls.isSupported()) {
        hls = new Hls({
          enableWorker: true,
          lowLatencyMode: true,
        })
        
        hls.loadSource(src)
        hls.attachMedia(video)
      } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
        video.src = src
      }
      
      return () => {
        if (hls) {
          hls.destroy()
        }
      }
    }
  }, [src])

  return (
    <video ref={videoRef} controls className="mx-auto w-full max-w-3xl" style={{ maxHeight: '70vh' }} poster={poster} />
  )
}

Page.tsx:

'use client'

import { VideoPlayer } from '@/components/HlsPlayer'
import React from 'react'

export default function VideoPage() {
  const videoUrl = 'http://localhost:8000/uploads/course/d90ac28e-a40a-49c3-9add-271ed44bb88e/index.m3u8'

  return (
    <div className="mx-auto mt-20 max-w-4xl p-4">
      <h1 className="mb-6 text-3xl font-bold">Video Player</h1>
      <div className="mb-6">
        <VideoPlayer src={videoUrl} poster="/static/img/batman.webp" />
      </div>
      <div className="mt-6">
        <h2 className="mb-2 text-xl font-semibold">Video Information</h2>
        <p className="text-sm text-gray-600">
          Video URL: <code className="rounded bg-gray-100 p-1">{videoUrl}</code>
        </p>
      </div>
    </div>
  )
}

2. 错误处理:

a. CORS 错误:

  • 问题:浏览器阻止向不同来源的请求。
  • 解决方案:在 Express 服务器中配置 CORS 标头,并更新 next.config.js 以包含允许的来源。

b. 内容安全策略 (CSP) 错误:

  • 问题:由于 CSP 设置,媒体资源被阻止。
  • 解决方案:调整 CSP 标头,允许来自服务器的媒体源。

3. CSP配置示例代码:

next.config.js(无随机数):

const cspHeader = `
    default-src 'self';
    script-src 'self' 'unsafe-eval' 'unsafe-inline';
    style-src 'self' 'unsafe-inline';
    img-src 'self' blob: data:;
    font-src 'self';
    media-src 'self' blob: ${process.env.LOCAL_FRONTEND_URL};
    connect-src 'self' ${process.env.LOCAL_API_URL};
    object-src 'none';
    base-uri 'self';
    form-action 'self';
    frame-ancestors 'none';
    upgrade-insecure-requests;
`
 
module.exports = {
  async headers() {
    return [
      {
        source: '/(.*)',
        headers: [
          {
            key: 'Content-Security-Policy',
            value: cspHeader.replace(/\n/g, ''),
          },
        ],
      },
    ]
  },
}

middleware.ts(使用随机数):

import { NextRequest, NextResponse } from 'next/server'
 
export function middleware(request: NextRequest) {
  const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
  const cspHeader = `
    default-src 'self';
    script-src 'self' 'nonce-${nonce}' 'strict-dynamic';
    style-src 'self' 'nonce-${nonce}';
    img-src 'self' blob: data:;
    font-src 'self';
    object-src 'none';
    base-uri 'self';
    form-action 'self';
    frame-ancestors 'none';
    upgrade-insecure-requests;
`
  // Replace newline characters and spaces
  const contentSecurityPolicyHeaderValue = cspHeader
    .replace(/\s{2,}/g, ' ')
    .trim()
 
  const requestHeaders = new Headers(request.headers)
  requestHeaders.set('x-nonce', nonce)
 
  requestHeaders.set(
    'Content-Security-Policy',
    contentSecurityPolicyHeaderValue
  )
 
  const response = NextResponse.next({
    request: {
      headers: requestHeaders,
    },
  })
  response.headers.set(
    'Content-Security-Policy',
    contentSecurityPolicyHeaderValue
  )
 
  return response
}

挑战与解决方案

  1. CORS 配置:
  • 问题:浏览器阻止对后端的请求。
  • 解决方案:更新 Express 中的 CORS 设置以允许特定来源。

2. CSP 错误:

  • 问题:由于 CSP 限制而阻止媒体资源。
  • 解决方案:调整了next.config.js中CSP 标头;并在middleware.ts 中使用了nonce

3. 多文件处理:

  • 问题:管理大文件上传和唯一文件命名。
  • 解决方案:配置 Multer 的文件大小限制,并使用 UUID 作为唯一的文件名。

结论

构建视频流平台涉及多种技术,需要克服多个挑战。通过利用 Node.js、FFmpeg、Multer 和 Next.js,创建了一个强大且可扩展的解决方案。尽管遇到了与 CORS、CSP 和文件处理相关的错误,我还是解决了这些问题,并提供了无缝的流媒体体验。

如何使用 Node.js、FFmpeg 和 Next.js 构建全栈视频流平台:综合指南

作者:Paudelronish

版权声明:本文内容转自互联网,本文观点仅代表作者本人。本站仅提供信息存储空间服务,所有权归原作者所有。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至1393616908@qq.com 举报,一经查实,本站将立刻删除。

(0)

相关推荐

发表回复

登录后才能评论