영상 스트리밍 #8. 스트리밍(HLS) - 자동 화질 조절

Bobby·2023년 3월 24일
0

streaming

목록 보기
8/9

화질 별로 스트리밍 할 수 있도록 해보자.


🎥 개요

다음 상황을 가정하고 테스트 한다.

  1. 영상 업로드(tus protocol)
  2. 영상 컨버팅 mp4 -> hls (1080p, 720p, 480p)
  3. 영상 스트리밍

🎥 영상 업로드

  • tus protocol 사용하여 업로드한다.
  • 영상 업로드 완료!

🎥 화질 별 컨버팅

  • 컨버팅은 ffmpeg 를 사용한다.

컨트롤러

  • 업로드 된 영상의 경로를 알기 위해 date, filename 을 받는다.
    ex) date : 2023-03-24
    filename : f656c26134e147559b4d7d4c212a8d7b.mp4

ConvertControllerV2

@RestController
@RequiredArgsConstructor
public class ConvertControllerV2 {

    private final ConvertServiceV2 convertServiceV2;

    @PostMapping("/v2/convert/hls/{date}/{filename}")
    public String convertToHls(
            @PathVariable String date,
            @PathVariable String filename
    ) {
        convertServiceV2.convertToHls(date, filename);
        return "success";
    }
}

서비스

  • 영상 파일(mp4)을 FHD(1080p), HD(720p), SD(480p) 세개의 화질로 컨버팅(hls) 한다.
  • 대역폭에 따라 화질을 변경 할 수 있도록 각각 생성된 playlist.m3u8 파일을 참조하는 master.m3u8 파일을 생성한다.
  • ffmpeg 옵션은 간단하게 구성했고 상황에 따라 필요한 옵션이 다르므로 ffmpeg 옵션에 대한 자세한 내용은 공식 홈페이지를 참고!

ConvertServiceV2

@Slf4j
@Service
@RequiredArgsConstructor
public class ConvertServiceV2 {

	// FFmpeg, FFprobe 는 빈으로 등록해 두었다.
    private final FFmpeg fFmpeg;
    private final FFprobe fFprobe;

    @Value("${tus.save.path}")
    private String savedPath;

    @Value("${tus.output.path.hls}")
    private String hlsOutputPath;

    public void convertToHls(String date, String filename) {

        // 입력 파일 경로 설정
        Path inputFilePath = Paths.get(savedPath + "/" + date + "/" + filename);

        // 출력 폴더 경로 설정
        Path outputFolderPath = Paths.get(hlsOutputPath + "/" + filename.split("\\.")[0]);

		// 화질 별 폴더 생성
        File prefix = outputFolderPath.toFile();
        File _1080 = new File(prefix, "1080");
        File _720 = new File(prefix, "720");
        File _480 = new File(prefix, "480");

        if (!_1080.exists()) _1080.mkdirs();
        if (!_720.exists()) _720.mkdirs();
        if (!_480.exists()) _480.mkdirs();

		// ffmpeg 명령어 생성
        FFmpegBuilder builder = new FFmpegBuilder()
                .setInput(inputFilePath.toAbsolutePath().toString())
                .addExtraArgs("-y")
                .addOutput(outputFolderPath.toAbsolutePath() + "/%v/playlist.m3u8") // 출력 위치
                .setFormat("hls")
                .addExtraArgs("-hls_time", "10") // chunk 시간
                .addExtraArgs("-hls_list_size", "0") 
                .addExtraArgs("-hls_segment_filename", outputFolderPath.toAbsolutePath() + "/%v/output_%03d.ts") // ts 파일 이름 (ex: output_000.ts)
                .addExtraArgs("-master_pl_name", "master.m3u8") // 마스터 재생 파일
                .addExtraArgs("-map", "0:v")
                .addExtraArgs("-map", "0:v")
                .addExtraArgs("-map", "0:v")
                .addExtraArgs("-var_stream_map", "v:0,name:1080 v:1,name:720 v:2,name:480") // 출력 매핑

				// 1080 화질 옵션
                .addExtraArgs("-b:v:0", "5000k")
                .addExtraArgs("-maxrate:v:0", "5000k")
                .addExtraArgs("-bufsize:v:0", "10000k")
                .addExtraArgs("-s:v:0", "1920x1080")
                .addExtraArgs("-crf:v:0", "15")
                .addExtraArgs("-b:a:0", "128k")

				// 720 화질 옵션
                .addExtraArgs("-b:v:1", "2500k")
                .addExtraArgs("-maxrate:v:1", "2500k")
                .addExtraArgs("-bufsize:v:1", "5000k")
                .addExtraArgs("-s:v:1", "1280x720")
                .addExtraArgs("-crf:v:1", "22")
                .addExtraArgs("-b:a:1", "96k")

				// 480 화질 옵션
                .addExtraArgs("-b:v:2", "1000k")
                .addExtraArgs("-maxrate:v:2", "1000k")
                .addExtraArgs("-bufsize:v:2", "2000k")
                .addExtraArgs("-s:v:2", "854x480")
                .addExtraArgs("-crf:v:2", "28")
                .addExtraArgs("-b:a:2", "64k")
                .done();

        run(builder);
    }

    private void run(FFmpegBuilder builder) {
        FFmpegExecutor executor = new FFmpegExecutor(fFmpeg, fFprobe);

        executor
                .createJob(builder, progress -> {
                    log.info("progress ==> {}", progress);
                    if (progress.status.equals(Progress.Status.END)) {
                        log.info("================================= JOB FINISHED =================================");
                    }
                })
                .run();
    }
}
  • %v 옵션값은 -var_stream_map 옵션에서 지정한 각 출력의 name 을 넣어준다. (name 을 설정하지 않으면 0, 1, 2, ...)
  • master.m3u8 파일을 출력하기 위해서는 -hls_list_size 옵션을 넣어주어야 한다.

실행

POST http://localhost:8080/v2/convert/hls/2023-03-24/f656c26134e147559b4d7d4c212a8d7b.mp4

  • 포스트맨 이용
  • 완료

  • 각 화질별로 ts, m3u8 파일이 생성 되었다.

  • 각 playlist.m3u8 파일에는 ts 파일의 위치 정보가 저장 되어있다.

  • master.m3u8 파일에는 BANDWIDTH 에 따라 playlist.m3u8 을 지정해준다.


🎥 스트리밍

  • 처음에는 master.m3u8 파일을 읽는다.
  • master.m3u8 파일을 읽고 대역폭에 따라서 playlist.m3u8 파일이 있는 경로로 요청한다.
  • playlist.m3u8 파일을 읽고 ts 파일을 재생한다.

컨트롤러

HlsControllerV2

@Controller
@RequiredArgsConstructor
public class HlsControllerV2 {

    private final HlsServiceV2 hlsServiceV2;

	// master.m3u8 경로
    @ResponseBody
    @RequestMapping("/v2/hls/{key}/{filename}")
    public ResponseEntity<InputStreamResource> getMaster(
            @PathVariable String key,
            @PathVariable String filename
    ) throws FileNotFoundException {
        File file = hlsServiceV2.getHlsFileV2(key, filename);
        InputStreamResource resource = new InputStreamResource(new FileInputStream(file));
        return ResponseEntity.ok()
                .contentType(MediaType.parseMediaType("application/x-mpegURL"))
                .body(resource);
    }

	// 각 화질별 ts, m3u8 경로
    @ResponseBody
    @RequestMapping("/v2/hls/{key}/{resolution}/{filename}")
    public ResponseEntity<InputStreamResource> getPlaylist(
            @PathVariable String key,
            @PathVariable String resolution,
            @PathVariable String filename
    ) throws FileNotFoundException {
        File file = hlsServiceV2.getHlsFileV2(key, resolution, filename);
        InputStreamResource resource = new InputStreamResource(new FileInputStream(file));
        return ResponseEntity.ok()
                .contentType(MediaType.parseMediaType("application/x-mpegURL"))
                .body(resource);
    }

}

서비스

@Service
@RequiredArgsConstructor
public class HlsServiceV2 {

    @Value("${tus.output.path.hls}")
    private String outputPath;

    public File getHlsFileV2(String key, String resolution, String filename) {
        return new File(outputPath + "/" + key + "/"  + resolution + "/" + filename);
    }

    public File getHlsFileV2(String key, String filename) {
        return new File(outputPath + "/" + key + "/" + filename);
    }
}

실행 - 사파리

  • hls 를 지원하는 사파리에서 실행 한다.

http://localhost:8080/v2/hls/f656c26134e147559b4d7d4c212a8d7b/master.m3u8

다음과 같이 bandwidth 상황에 따라 화질을 변경하여 요청한다.

  • master.m3u8 요청
  • 1080playlist.m3u8 요청
  • 480playlist.m3u8 재요청
  • 각 화질별로 직접 요청할 수도 있다.
    http://localhost:8080/v2/hls/f656c26134e147559b4d7d4c212a8d7b/720/playlist.m3u8

실행 - 플레이어(video.js) 사용

hls_player.html

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <title>Uploaded vod</title>

    <link href="https://vjs.zencdn.net/7.14.3/video-js.css" rel="stylesheet" />
    <script src="https://vjs.zencdn.net/7.14.3/video.min.js"></script>
</head>
<body>
<video id="my-video" class="video-js" controls preload="auto" width="720" height="480">
    <source src="http://localhost:8080/v2/hls/f656c26134e147559b4d7d4c212a8d7b/master.m3u8" type="application/x-mpegURL">
</video>
<script>
    var player = videojs('my-video');
    player.play();
</script>
</body>
</html>


코드

profile
물흐르듯 개발하다 대박나기

1개의 댓글

comment-user-thumbnail
2024년 5월 25일

배우고 갑니다

답글 달기