관리 메뉴

Never give up

HLS - 2. converting and serving server 본문

HLS

HLS - 2. converting and serving server

대기만성 개발자 2025. 3. 19. 17:08
반응형

이번에는 일반 영상을 올릴 때, FFmpeg으로 변환하는 과정과

 

영상 데이터를 클라이언트에 전달하는 부분을 보도록 하겠습니다

 

index.ts

const app = express();

const options = {
  origin: "http://localhost:3000",
  credentials: true,
  optionsSuccessStatus: 200,
};

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

app.use("/api/video", video);

app.listen(8080, () => {
  console.log(`[server] running on localhost:${8080} `);
});

현재 /api/video만 사용하고 있습니다

 

route/video

const router = Router();

router.get("/list", videoController.getList);
router.get("/:videoId/master.m3u8", videoController.getMaster)
router.get("/:videoId/:name/index.m3u8", videoController.getVideo);
router.get("/:videoId/:name/:segment", videoController.getSegment);
router.post("/upload", videoController.upload);

export default router;

먼저 사용하는 api는 총 5개로

 

1. /list: 영상 리스트 조회

2. /:videoId/master.m3u8: master 조회

3. /:videoId/:name/index.m3u8: 화질(name)에 따른 index.m3u8 조회

4. /:videoId/:name/:segment: 화질과 영상 순서에 따른 segment 조회

5. /upload: 영상 업로드

 

models/ResolutionModel.ts

export interface ResolutionModel {
  name: string;
  width: number;
  height: number;
  bitrate: string;
  audioBitrate: string;
}

영상 화질, 너비/높이, bitrate, audio bitrate를 정의 한 모델로

 

해당 예제에서는 360p, 720p부분만 해당 모델에 매핑해서 사용했습니다

 

controllers/videoController.ts

import { spawn } from "child_process";
import { Request, Response } from "express";
import formidable from "formidable";
import { accessSync, mkdirSync, readdir, statSync, writeFileSync } from "fs";
import { cpus } from "os";
import path from "path";
import { STATUS_CODE } from "../constants/code";
import {
  fileConst,
  hlsDir,
  MASTER_FORMAT,
  resolutions,
} from "../constants/file";
import { ResolutionModel } from "../models/ResolutionModel";

class VideoController {
  private cores = cpus().length;

  public getList(_: Request, res: Response) {
    readdir(hlsDir, (err, files) => {
      if (err) {
        res
          .status(STATUS_CODE.serverError)
          .json({ msg: "Error reading video folders" });
        return;
      }

      const list = files.filter((file) =>
        statSync(path.join(hlsDir, file)).isDirectory()
      );
      console.log("[video] get list", { list });
      res.status(STATUS_CODE.ok).json(list);
    });
  }

  public async getMaster(req: Request, res: Response) {
    const videoId = req.params.videoId;
    const masterPath = path.join(hlsDir, videoId, "master.m3u8");

    console.log("[video] get master", { masterPath });

    try {
      accessSync(masterPath);
      res.header("Content-Type", "application/vnd.apple.mpegurl");
      res.sendFile(masterPath);
    } catch (err) {
      res.status(STATUS_CODE.clientError).send({ msg: "File doesn't exist" });
    }
  }

  public async getVideo(req: Request, res: Response) {
    const videoId = req.params.videoId;
    const name = req.params.name;
    const m3u8Path = path.join(hlsDir, videoId, name, "index.m3u8");

    console.log("[video] get m3u8", { m3u8Path });

    try {
      accessSync(m3u8Path);
      res.header("Content-Type", "application/vnd.apple.mpegurl");
      res.sendFile(m3u8Path);

      console.log("[video] m3u8");
    } catch (err) {
      res.status(STATUS_CODE.clientError).send({ msg: "File doesn't exist" });
    }
  }

  public async getSegment(req: Request, res: Response) {
    const videoId = req.params.videoId;
    const name = req.params.name;
    const segment = req.params.segment;
    const segmentPath = path.join(hlsDir, videoId, name, segment);

    console.log("[video] get segment", { segmentPath });

    try {
      accessSync(segmentPath);
      res.header("Content-Type", "video/mp2t");
      res.sendFile(segmentPath);
      console.log("[video] segment");
    } catch (err) {
      res.status(STATUS_CODE.clientError).send({ msg: "Segment not found." });
    }
  }

  public upload = async (req: Request, res: Response) => {
    console.log("[upload] video");

    const form = formidable({
      keepExtensions: true,
      multiples: false,
      maxFileSize: fileConst.fileSize,
      filename(_, __, part) {
        const ext = part.originalFilename?.split(".").pop();

        return `${Date.now()}.${ext}`;
      },
    });

    try {
      const [, files] = await form.parse(req);

      if (!files.file) {
        res
          .status(STATUS_CODE.clientError)
          .json({ msg: "Invalid file format." });
        return;
      }

      const file = files.file[0];
      const filePath = file.filepath;
      const outputFolder = path.join(hlsDir, path.parse(filePath).name);

      await this._convertToHLS(filePath, outputFolder);

      res.status(STATUS_CODE.ok).json({ msg: "Upload & conversion complete" });
    } catch (err) {
      console.error("[upload] Conversion error:", err);
      res.status(STATUS_CODE.serverError).json({ msg: `${err}` });
    }
  };

  private _convertToHLS = async (
    inputFilePath: string,
    outputFolder: string
  ) => {
    mkdirSync(outputFolder, { recursive: true });

    const conversionPromises = resolutions.map((resolution) =>
      this._convertSingleBitrate({ ...resolution, inputFilePath, outputFolder })
    );

    try {
      await Promise.all(conversionPromises);

      writeFileSync(path.join(outputFolder, "master.m3u8"), MASTER_FORMAT);
      console.log("[convert] Master M3U8 file created!");
    } catch (error) {
      console.error("[convert] Error in FFmpeg conversion:", error);
    }
  };

  private _convertSingleBitrate = ({
    name,
    width,
    height,
    bitrate,
    audioBitrate,
    inputFilePath,
    outputFolder,
  }: ResolutionModel & { outputFolder: string; inputFilePath: string }) => {
    return new Promise<void>((res, rej) => {
      const resFolder = path.join(outputFolder, name);
      mkdirSync(resFolder, { recursive: true });

      const ffmpegProcess = spawn("ffmpeg", [
        "-i",
        inputFilePath,
        "-preset",
        "ultrafast",
        "-g",
        "100",
        "-sc_threshold",
        "0",
        "-hls_time",
        "10",
        "-hls_list_size",
        "0",
        "-hls_segment_filename",
        path.join(resFolder, "segment_%03d.ts"),
        "-f",
        "hls",
        "-threads",
        `${this.cores}`,
        "-c:v",
        "libx264",
        "-b:v",
        bitrate,
        "-s",
        `${width}x${height}`,
        "-c:a",
        "aac",
        "-b:a",
        audioBitrate,
        path.join(resFolder, "index.m3u8"),
      ]);

      ffmpegProcess.stdout.on("data", (data) =>
        console.log(`[FFmpeg ${name} stdout]: ${data}`)
      );
      ffmpegProcess.stderr.on("data", (data) =>
        console.error(`[FFmpeg ${name} stderr]: ${data}`)
      );

      ffmpegProcess.on("close", (code) => {
        if (code === 0) {
          const m3u8Path = `/static/hls/${path.basename(resFolder)}/index.m3u8`;
          console.log("[convert] Success! HLS file:", m3u8Path);
          res();
        } else {
          console.log("[convert] FFmpeg exited with error code:", code);
          rej(new Error(`FFmpeg failed with code ${code}`));
        }
      });
    });
  };
}

export default new VideoController();

1. getList는 영상이 저장된 폴더를 조회해서 클라이언트에 전달합니다

2. getMaster, getVideo, getSegment는 생성된 텍스트, 영상 파일을 클라이언트에 전달합니다

3. upload는 클라이언트에서 업로드한 비디오를 FFmpeg을 이용해서 HLS를 사용할 수 있는 포맷으로 변환하고, 클라이언트에 메시지를 전달합니다

 

FFmpeg부분도 간단하게 정리해봤습니다(GPT님 감사합니다)

"-i", inputFilePath // 원본 동영상
"-preset", "ultrafast" // 인코딩 속도 (빠를수록 파일 크기 작음)
"-g", "100" // 키 프레임으로 100프레임마다 키프레임을 생성 30fps 기준 3.3초마다 키프레임 생성, 숫자가 클수록 압축률이 높고 탐색이 어려움
"-sc_threshold", "0" // 자동 키프레임 삽입 비활성
"-hls_time", "10" // 세그먼트 길이(세그먼트 분할 초)
"-hls_list_size", "0" // 모든 세그먼트를 리스트에 유지
"-hls_segment_filename", path.join(resFolder, "segment_%03d.ts") // 세그먼트 파일네임 지정
"-f", "hls" // 출력 형식을 HLS로 설정
"-threads", `${this.cores} // 성능최적화로 n개의 코어 사용
"-c:v", "libx264" // 비디오 코덱 설정 (H.264)
"-b:v", bitrate // 비디오 비트레이트 설정
"-s", `${width}x${height}` // 출력 해상도 설정
"-c:a", "aac" // 오디오 코덱 aac로 설정
"-b:a", audioBitrate // 오디오 비트레이트 설정
path.join(resFolder, "index.m3u8") // 출력위치 설정

// 해당 예제에 미적용한 별도 옵션
"-crf", "23" // 일정한 품질 유지 (낮을수록 고화질)
"-maxrate", bitrate // 최대 비트레이트 제한
"-bufsize", "2M" // 비트레이트 변동 부드럽게 처리
"-tune", "zerolatency" // 저지연으로 실시간 스트리밍할 때 사용

 

constants/file.ts

import path from "path";
import { ResolutionModel } from "../models/ResolutionModel";

export const fileConst = {
  fileSize: 500 * 1024 * 1024,
} as const;

export const resolutions: ResolutionModel[] = [
  {
    name: "360p",
    width: 640,
    height: 360,
    bitrate: "800k",
    audioBitrate: "64k",
  },
  {
    name: "720p",
    width: 1280,
    height: 720,
    bitrate: "2500k",
    audioBitrate: "128k",
  },
] as const;

export const MASTER_FORMAT = `#EXTM3U
#EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360,NAME="360"
360p/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=1280x720,NAME="720"
720p/index.m3u8
`;

export const hlsDir = path.join(__dirname, "../../static/hls");

해당 부분은 file max size, bitrate설정 부분, master.m3u8 text 정의, 저장 위치를 정의해주는 부분입니다

 

이전 intro에서 언급했던 부분은데, index.m3u8은 FFmpeg에서 생성되지만

 

master.m3u8은 별도로 생성 해줍니다

 

서버는 이정도로 정리해보면 될거같고, 다음에는 클라이언트 부분입니다

 

Intro : https://devmemory.tistory.com/130

React client : https://devmemory.tistory.com/132

Outro : https://devmemory.tistory.com/133

반응형

'HLS' 카테고리의 다른 글

HLS - 4. outro  (0) 2025.03.19
HLS - 3. HLS player client  (0) 2025.03.19
HLS - 1. Intro  (0) 2025.03.19
Comments