스트리밍 - HLS 프로토콜

박선규's avatar
Jun 25, 2024
스트리밍 - HLS 프로토콜

정의

주로 소리(음악)나 동영상 등의 다중매체 파일을 전송하고 재생하는 방식의 하나이다.
파일을 다운로드 하는 것과 더불어 재생을 함으로써 기다리는 시간을 크게 줄일 수 있다.
 
클라이언트 입장에서 봤을 때
별도의 파일을 다운로드 하지 않고
웹 페이지나 스트리밍 서비스에 접속해 실시간으로 재생하는 시스템입니다.
다들 어렸 을 때 보고싶던 영화나 애니매이션이나 드라마가 있었다면 동영상 파일을 다운 받고 곰플레이어 같은 재생 프로그램을 이용해서 동영상 재생을 했을 겁니다.
그러나 스트리밍은 클라이언트의 별도 요청 없이도 넷플릭스
 
 

동영상과 스트리밍의 차이

스트리밍은 실시간이며 미디어 파일을 다운로드하는 것보다 더 효율적입니다. 동영상 파일을 다운로드하면 전체 파일의 사본이 장치의 하드 드라이브에 저장되며, 전체 파일 다운로드가 완료될 때까지 동영상을 재생할 수 없습니다. 대신 스트리밍하는 경우 브라우저는 동영상을 실제로 복사하여 저장하지 않고 재생합니다. 동영상은 전체 파일이 한 번에 로드되는 대신 한 번에 조금씩 로드되며, 브라우저가 로드하는 정보는 로컬에 저장되지 않습니다

동영상 다운로드

  • 위 사이트 들아가서 동영상 다운 할 수 있는 프로그램 다운받기
notion image
다운 하려는 영상의 url을 입력하기
다운 하려는 영상의 url을 입력하기
다운로드 누르기
다운로드 누르기
 
다운 된 것을 확인 할수 있음
다운 된 것을 확인 할수 있음
notion image

동영상 적용

  • 정적 리소스 설정: 스프링 부트에서는 이러한 정적 리소스 디렉토리를 지정해야 웹 애플리케이션에서 접근할 수 있습니다. 위에서 언급한대로 application.properties 또는 application.yml 파일을 사용하여 spring.resources.static-locations를 설정하여 정적 리소스를 등록할 수 있습니다.
 

HLS

📌
HLS(HTTP Live Streaming)는 주로 인터넷을 통해 동영상 콘텐츠를 스트리밍하기 위해 사용되는 프로토콜입니다. Apple Inc.에서 개발되었으며, 다양한 네트워크 환경에서도 안정적인 스트리밍을 제공할 수 있도록 설계되었다. HLS는 특히 인터넷 연결이 불안정하거나 대역폭이 제한된 환경에서 유용하다. HLS에 대한 주요 개념을 아래에 정리했습니다.

HLS의 주요 개념

  1. 세그먼트 (Segment)
      • HLS는 전체 비디오를 작은 조각(세그먼트)으로 나누어 전송합니다. 각 세그먼트는 일반적으로 몇 초 길이의 TS(Transport Stream) 파일입니다. 이러한 세그먼트는 클라이언트가 비디오를 스트리밍하는 동안 다운로드하여 재생합니다.
  1. M3U8 플레이리스트
      • 세그먼트 파일의 목록을 포함하는 텍스트 파일입니다. 클라이언트는 이 파일을 다운로드하여 어떤 세그먼트가 어떤 순서로 재생되어야 하는지 파악합니다. M3U8 파일에는 세그먼트의 URL, 지속 시간, 시퀀스 번호 등의 정보가 포함됩니다.
  1. 어댑티브 비트레이트 스트리밍 (Adaptive Bitrate Streaming)
      • HLS는 네트워크 상태에 따라 동적으로 비트레이트를 조정할 수 있는 기능을 제공합니다. 여러 품질(비트레이트) 옵션을 포함하는 여러 개의 플레이리스트를 제공하며, 클라이언트는 현재 네트워크 상태에 따라 적절한 품질의 세그먼트를 선택하여 재생합니다.
      • 해상도: 비디오의 프레임 크기를 나타냅니다. 일반적으로 가로 픽셀 수 x 세로 픽셀 수로 표현됩니다 (예: 1920x1080, 1280x720).
      • 비트레이트: 비디오 데이터의 전송 속도를 나타내며, 초당 비트 수 (bps)로 측정됩니다. 높은 비트레이트는 더 나은 품질을 제공하지만, 더 많은 대역폭을 필요로 합니다.
  1. 연속 스트리밍
      • HLS는 라이브 스트리밍을 지원하며, 라이브 이벤트를 실시간으로 전송할 수 있습니다. 새로운 세그먼트가 지속적으로 생성되고 플레이리스트가 업데이트되어 실시간으로 재생됩니다.
  1. 재생 호환성
      • HLS는 HTTP를 사용하여 동영상을 전송하기 때문에 대부분의 방화벽과 프록시 서버를 통과할 수 있습니다. 또한, iOS 및 macOS 디바이스에서 기본적으로 지원되며, Android와 Windows를 포함한 다른 플랫폼에서도 지원할 수 있도록 다양한 플레이어와 라이브러리가 개발되었습니다.

HLS의 동작 과정

  1. 인코딩 및 세그먼트화
      • 서버는 원본 비디오를 인코딩하여 여러 비트레이트 버전으로 나누고, 각 버전을 일정한 길이의 세그먼트로 분할합니다.
  1. 플레이리스트 생성
      • 각 비트레이트 버전의 세그먼트를 포함하는 M3U8 플레이리스트 파일을 생성합니다. 이러한 플레이리스트는 각 세그먼트의 URL을 포함합니다.
  1. 클라이언트 요청
      • 클라이언트는 초기 M3U8 파일을 다운로드하여 재생을 시작합니다. 네트워크 상태에 따라 적절한 품질의 세그먼트를 다운로드하여 재생합니다
  1. 재생 및 버퍼링
      • 클라이언트는 세그먼트를 버퍼링하고, 연속적으로 재생하여 중단 없는 스트리밍을 제공합니다. 네트워크 상태가 변경되면 클라이언트는 자동으로 비트레이트를 조정합니다.
HLS는 안정적이고 확장 가능한 스트리밍 솔루션을 제공하며, 다양한 네트워크 조건에서도 고품질의 비디오 스트리밍을 가능하게 합니다.
 

진행 순서

클라이언트가 먼저 동영상을 서버에 업로드하고 이 업로드된 동영샹을 실시간으로 재생을 시킨다.
 
package com.example.stramapp; import org.springframework.core.io.Resource; import org.springframework.core.io.UrlResource; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import java.net.MalformedURLException; import java.nio.file.Path; import java.nio.file.Paths; @CrossOrigin @RestController @RequestMapping("/videos") public class VideoController { private final VideoService videoService; public VideoController(VideoService videoService) { this.videoService = videoService; } private final Path videoLocation = Paths.get(System.getProperty("user.dir"), "video"); @PostMapping("/upload") public ResponseEntity<String> uploadVideo(@RequestParam("file") MultipartFile file) { System.out.println(1); try { System.out.println(2); String m3u8Filename = videoService.uploadAndEncodeVideo(file); System.out.println(3); return ResponseEntity.ok(m3u8Filename); } catch (Exception e) { System.out.println(4); return ResponseEntity.status(500).body("Failed to upload and encode video: " + e.getMessage()); } } @GetMapping("/{filename:.+}") public ResponseEntity<Resource> getVideo(@PathVariable String filename) { try { Path file = videoLocation.resolve(filename); Resource resource = new UrlResource(file.toUri()); String contentType = "application/vnd.apple.mpegurl"; if (filename.endsWith(".ts")) { contentType = "video/MP2T"; } return ResponseEntity.ok() .header(HttpHeaders.CONTENT_TYPE, contentType) .body(resource); } catch (MalformedURLException e) { return ResponseEntity.badRequest().build(); } } }
upload 먼저

프로젝트 적용

스트리밍 시스템으로 영상 업로드하기

dependencies { // https://github.com/bytedeco/javacv/#manual-installation implementation 'org.bytedeco:javacv-platform:1.5.10' }
public String uploadAndEncodeVideo(MultipartFile file) throws IOException { try { // 비디오 디렉토리 생성 Files.createDirectories(videoLocation); } catch (IOException e) { log.warn("Failed to create video directory: " + e.getMessage()); throw new RuntimeException("Could not create video directory", e); } // 원본 파일 이름 및 경로 설정 String originalFilename = file.getOriginalFilename(); if (originalFilename == null || originalFilename.isBlank()) { throw new IllegalArgumentException("파일 이름이 비어 있습니다."); } String baseName = FilenameUtils.getBaseName(originalFilename); String inputFilePath = videoLocation.resolve(originalFilename).toString(); File inputFile = new File(inputFilePath); // 업로드된 파일을 직접 읽어 저장 try (InputStream inputStream = file.getInputStream(); FileOutputStream outputStream = new FileOutputStream(inputFile)) { byte[] buffer = new byte[1024]; int bytesRead; while ((bytesRead = inputStream.read(buffer)) != -1) { outputStream.write(buffer, 0, bytesRead); } } // 다양한 비트레이트 설정 List<Integer> bitrates = List.of(400000, 800000, 1200000); // 비트레이트 설정 List<String> outputFiles = new ArrayList<>(); String masterPlaylistName = baseName + "_master.m3u8"; String masterPlaylistPath = videoLocation.resolve(masterPlaylistName).toString(); // 각 비트레이트별 M3U8 파일 생성 int imageWidth = 0; int imageHeight = 0; for (int bitrate : bitrates) { String outputFileName = baseName + "." + bitrate + "m3u8"; String outputFilePath = videoLocation.resolve(outputFileName).toString(); outputFiles.add(outputFilePath); // FFmpegFrameGrabber와 FFmpegFrameRecorder 설정 try (FFmpegFrameGrabber grabber = new FFmpegFrameGrabber(inputFilePath)) { grabber.start(); if (imageWidth == 0 || imageHeight == 0) { imageWidth = grabber.getImageWidth(); imageHeight = grabber.getImageHeight(); } try (FFmpegFrameRecorder recorder = new FFmpegFrameRecorder(outputFilePath, grabber.getImageWidth(), grabber.getImageHeight(), grabber.getAudioChannels())) { recorder.setFormat("hls"); recorder.setOption("hls_time", "10"); recorder.setOption("hls_list_size", "0"); recorder.setOption("hls_flags", "split_by_time"); recorder.setOption("hls_wrap", "0"); recorder.setOption("loglevel", "debug"); recorder.setVideoCodec(avcodec.AV_CODEC_ID_H264); recorder.setAudioCodec(avcodec.AV_CODEC_ID_AAC); recorder.setFrameRate(grabber.getFrameRate()); recorder.setSampleRate(grabber.getSampleRate()); recorder.setAudioChannels(grabber.getAudioChannels()); recorder.setVideoBitrate(bitrate); recorder.start(); Frame frame; while ((frame = grabber.grabFrame()) != null) { if (frame.image != null) { recorder.record(frame); } else if (frame.samples != null && frame.samples.length > 0) { recorder.recordSamples(frame.sampleRate, frame.audioChannels, frame.samples); } } recorder.stop(); } grabber.stop(); } catch (Exception e) { log.warn("비디오 인코딩 중 오류가 발생했습니다: " + e.getMessage()); throw new IOException("비디오 인코딩 중 오류가 발생했습니다: " + e.getMessage(), e); } } // 마스터 M3U8 플레이리스트 생성 try (BufferedWriter writer = new BufferedWriter(new FileWriter(masterPlaylistPath))) { writer.write("#EXTM3U\n"); for (int bitrate : bitrates) { String resolution = imageWidth + "x" + imageHeight; // 해상도 설정 writer.write("#EXT-X-STREAM-INF:BANDWIDTH=" + bitrate + ",RESOLUTION=" + resolution + "\n"); writer.write(baseName +"."+ bitrate + "m3u8\n"); } } return masterPlaylistName; // 마스터 M3U8 파일 이름 반환 }
스티리밍 시스템으로 동영상 업로드
public Movie addMovie(MovieRequest.MovieSavaFormDTO reqDTO) { // MultipartFile 객체로부터 포스터 파일 가져오기 MultipartFile poster = reqDTO.getImgFilename(); String posterFileName = null; try { // 포스터 파일 저장 및 파일 이름 설정 posterFileName = fileUtil.saveMoviePoster(poster); } catch (IOException e) { // 파일 저장 중 예외 발생 시 런타임 예외로 전환 throw new RuntimeException("이미지 오류", e); } // Movie 객체 빌더 패턴으로 생성 Movie movie = Movie.builder() .title(reqDTO.getTitle()) // 영화 제목 설정 .engTitle(reqDTO.getEngTitle()) // 영어 제목 설정 .director(reqDTO.getDirector()) // 감독 설정 .actor(reqDTO.getActor()) // 배우 설정 .genre(reqDTO.getGenre()) // 장르 설정 .info(reqDTO.getInfo()) // 기본 정보 설정 .startDate(reqDTO.getStartDate()) // 개봉일 설정 .endDate(reqDTO.getEndDate()) // 상영 종료일 설정 .imgFilename(posterFileName) // 포스터 파일 이름 설정 .description(reqDTO.getDescription()) // 영화 설명 설정 .build(); // Movie 객체를 데이터베이스에 저장하여 PK 값 생성 movie = movieRepository.save(movie); // 스틸컷 이미지 파일 처리 List<MoviePic> moviePicList = new ArrayList<>(); MultipartFile[] stills = reqDTO.getStills(); if (stills != null && stills.length > 0) { for (MultipartFile still : stills) { try { // 스틸컷 이미지 파일을 저장하고 파일명을 반환 String stillFileName = fileUtil.saveMovieStill(still); // MoviePic 객체 생성 및 파일명 설정 MoviePic moviePic = new MoviePic(); moviePic.setImgFilename(stillFileName); moviePic.setMovie(movie); // 외래 키 설정 // MoviePic 리스트에 추가 moviePicList.add(moviePic); } catch (IOException e) { throw new RuntimeException("스틸컷 이미지 오류", e); } } // MoviePic 리스트를 저장 moviePicRepository.saveAll(moviePicList); } // Movie 엔티티에 MoviePic 리스트 설정 movie.setMoviePicList(moviePicList); // 트레일러 파일 처리 List<Trailer> movieTrailerList = new ArrayList<>(); MultipartFile[] trailers = reqDTO.getTrailers(); if (trailers != null && trailers.length > 0) { for (MultipartFile trailer : trailers) { try { String masterMp4FileName = uploadAndEncodeVideo(trailer); String mp4FileName = trailer.getOriginalFilename(); Trailer movieTrailer = new Trailer(); movieTrailer.setStreamingFilename(mp4FileName); movieTrailer.setMasterM3U8Filename(masterMp4FileName); movieTrailer.setMovie(movie); // 외래 키 설정 movieTrailerList.add(movieTrailer); } catch (IOException e) { throw new RuntimeException("트레일러 파일 오류", e); } } trailerRepository.saveAll(movieTrailerList); } movie.setTrailerList(movieTrailerList); return movie; }
{{> layout/header-admin}} <style> #photoPreviewContainer { display: flex; flex-wrap: nowrap; overflow-x: auto; } #photoPreviewContainer .photo-item { position: relative; margin-right: 10px; width: 150px; height: 150px; display: flex; justify-content: center; align-items: center; box-sizing: border-box; } #photoPreviewContainer img { width: 100%; height: 100%; object-fit: cover; } #photoPreviewContainer .remove-btn { position: absolute; top: 5px; right: 5px; background-color: red; color: white; border: none; border-radius: 50%; cursor: pointer; } </style> </head> <body> <div class="container mt-5"> <h2>영화 등록</h2> <hr> <form action="/movie-save" method="post" enctype="multipart/form-data" id="movieForm"> <div class="form-group mt-4"> <div class="d-flex justify-content-center align-items-center" style="width: 250px; height: 300px; border: 2px solid #E6E6E6; color: #E6E6E6;" id="profilePreview"> <b id="placeholderText">사진을 등록해 주세요.</b> <img id="photoPreview" src="#" alt="Photo Preview" style="display: none; width: 250px; height: 300px;"> </div> <div class="file-input-container mt-4"> <input type="file" class="form-control-file" id="moviePhotos" name="imgFilename" accept="image/*" onchange="previewImage();"> </div> </div> <div class="d-flex" style="justify-content: space-between; column-gap: 20px;"> <div style="width: 100%;"> <div class="form-group"> <label for="movieTitle">영화 제목</label> <input type="text" class="form-control" id="movieTitle" name="title" placeholder="영화 제목을 입력하세요"> </div> <div class="form-group"> <label for="basicInfo">기본 정보</label> <input type="text" class="form-control" id="basicInfo" name="info" placeholder="기본 정보를 입력하세요"> </div> <div class="form-group"> <label for="director">감독</label> <input type="text" class="form-control" id="director" name="director" placeholder="감독을 입력하세요"> </div> <div class="form-group"> <label for="releaseDate">개봉일</label> <input type="date" class="form-control" id="releaseDate" name="startDate"> </div> </div> <div style="width: 100%;"> <div class="form-group"> <label for="movieTitle">영문 제목</label> <input type="text" class="form-control" id="engTitle" name="engTitle" placeholder="영어 제목을 입력하세요"> </div> <div class="form-group"> <label for="movieGenre">장르</label> <input type="text" class="form-control" id="movieGenre" name="genre" placeholder="영화 장르를 입력하세요"> </div> <div class="form-group"> <label for="actors">배우</label> <input type="text" class="form-control" id="actors" name="actor" placeholder="배우를 입력하세요"> </div> <div class="form-group"> <label for="endDate">상영 종료일</label> <input type="date" class="form-control" id="endDate" name="endDate"> </div> </div> </div> <div class="form-group"> <label for="synopsis">영화 설명</label> <textarea class="form-control" id="synopsis" name="description" rows="15" placeholder="영화에 대한 설명을 입력하세요"></textarea> </div> <div class="form-group"> <label for="stills">스틸컷</label> <div id="photoPreviewContainer"></div> <input type="file" class="form-control-file" id="stills" name="stills" accept="image/*" onchange="previewImages();" multiple> </div> <div class="form-group"> <label for="trailer">트레일러</label> <div id="videoPreviewContainer"> <video id="videoPreview" width="320" height="240" controls style="display: none;"></video> </div> <input type="file" class="form-control-file" id="trailer" name="trailers" accept="video/*" onchange="previewVideo();" multiple> </div> <button type="submit" class="btn btn-primary">등록</button> </form> </div> <script> let stillFiles = []; function previewImage() { var file = document.getElementById("moviePhotos").files[0]; var reader = new FileReader(); reader.onload = function (e) { var imgElement = document.getElementById("photoPreview"); var placeholderText = document.getElementById("placeholderText"); imgElement.src = e.target.result; imgElement.style.display = "block"; placeholderText.style.display = "none"; }; if (file) { reader.readAsDataURL(file); } } function previewImages() { var files = document.getElementById("stills").files; var previewContainer = document.getElementById("photoPreviewContainer"); previewContainer.innerHTML = ""; for (var i = 0; i < files.length; i++) { var file = files[i]; stillFiles.push(file); var reader = new FileReader(); reader.onload = (function (file) { return function (e) { var photoItem = document.createElement("div"); photoItem.classList.add("photo-item"); var imgElement = document.createElement("img"); imgElement.src = e.target.result; var removeBtn = document.createElement("button"); removeBtn.classList.add("remove-btn"); removeBtn.innerText = "X"; removeBtn.onclick = function () { var index = stillFiles.indexOf(file); if (index > -1) { stillFiles.splice(index, 1); } previewContainer.removeChild(photoItem); }; photoItem.appendChild(imgElement); photoItem.appendChild(removeBtn); previewContainer.appendChild(photoItem); }; })(file); if (file) { reader.readAsDataURL(file); } } } function previewVideo() { var fileInput = document.getElementById('trailer'); var file = fileInput.files[0]; var videoPreview = document.getElementById('videoPreview'); if (file) { videoPreview.src = URL.createObjectURL(file); videoPreview.style.display = 'block'; } } </script> <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script> <script src="https://code.jquery.com/jquery-3.5.1.slim.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.5.4/dist/umd/popper.min.js"></script> <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script> {{> layout/footer}}
notion image
notion image
📌
ts 파일:비디오를 세그먼트 단위로 쪼갠 데이터들이 들어있는파일 비트레이트별 m3u8파일:ts 파일의 주소와 목록들이 있다. 해상도 별로 나뉘어진다. MASTER m3u8파일: 자동으로 사용자의 네트워크 환경에 따라 자동으로 적절한 해상도에 맞는 파일로 연결 시켜준다.
notion image

스트리밍 시스템으로 영상 재생하기

📌
사진 처럼 동영상도 main 예고편은 movie 필드로 받고 나머지것들을 트레일러의 받도록
implementation 'net.bramp.ffmpeg:ffmpeg:0.8.0'
 
  • M3U8 파일 다운 컨트롤러
@GetMapping("/{hlsName}.m3u8") public ResponseEntity<Resource> getHls(@PathVariable String hlsName) throws IOException { Resource resource = hlsService.getVideoRes(hlsName + ".m3u8"); HttpHeaders headers = new HttpHeaders(); headers.set(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=good.m3u8"); headers.setContentType(MediaType.parseMediaType("application/vnd.apple.mpegurl")); return new ResponseEntity<>(resource, headers, HttpStatus.OK); }
📌
attachment로 설정하는 것은 HTTP 응답 헤더에서 Content-Disposition 속성을 설정하여 클라이언트가 해당 파일을 다운로드하게끔 하는 것을 의미합니다. Content-Disposition: attachment를 사용하면 브라우저는 파일을 즉시 열지 않고 다운로드할 수 있는 파일로 인식하여 사용자가 저장할 수 있게 합니다.
📌
Content-Disposition: Content-Disposition 헤더는 브라우저에게 콘텐츠가 인라인으로 표시될지 또는 다운로드되어야 하는지를 알리는 데 사용됩니다.
 
  • 파일 다운 서비스
public Resource getVideoRes(String tsFile) throws IOException { File file = new File(getConvertVideoPath(tsFile)); //경로 불러오기 getConvertVideoPath return new FileSystemResource(file.getCanonicalPath()); }
private String getConvertVideoPath(String outputFileName) { return "src/main/resources/convert/" + outputFileName; }
  • 역할: 파일 시스템에서 해당 파일을 가져오는 역할을 합니다.
  • 동작: 파일의 경로를 받아 FileSystemResource 객체로 반환합니다.
 
 
  • TS 파일 다운 컨트롤러
@GetMapping("/{tsName}.ts") public ResponseEntity<Resource> getHlsTs(@PathVariable String tsName) throws IOException { tsName = tsName + ".ts"; Resource resource = hlsService.getVideoRes(tsName); HttpHeaders headers = new HttpHeaders(); headers.set(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + tsName); headers.setContentType(MediaType.parseMediaType(MediaType.APPLICATION_OCTET_STREAM_VALUE)); return new ResponseEntity<>(resource, headers, HttpStatus.OK); }
 
  • HLS 변환 컨트롤러
@GetMapping("/convert") public ResponseEntity<?> convert() throws IOException { hlsService.convertHls(); return ResponseEntity.ok().build(); }
  • 역할: MP4 파일을 HLS 스트림으로 변환하는 작업을 트리거한다.
  • 동작: hlsService.convertHls() 메소드를 호출하여 변환 작업을 실행한다.
  • 응답: 작업이 성공적으로 실행되었음을 알리는 HTTP 200 응답을 반환합니다.
 
 
 
  • HLS 변환 서비스
public void convertHls() throws IOException { String videoFilePath = new File(getRawVideoPath("good.mp4")).getCanonicalPath(); String convertPath = new File(getConvertVideoPath("/good.m3u8")).getCanonicalPath(); FFmpegBuilder builder = new FFmpegBuilder() .setInput(videoFilePath) .addOutput(convertPath) .addExtraArgs("-profile:v", "baseline") .addExtraArgs("-level", "3.0") .addExtraArgs("-start_number", "0") .addExtraArgs("-hls_time", "10") .addExtraArgs("-hls_list_size", "0") .addExtraArgs("-f", "hls") .done(); FFmpegExecutor executor = new FFmpegExecutor(new FFmpeg(), new FFprobe()); executor.createJob(builder).run(); }
  • 역할: MP4 파일을 HLS 스트림으로 변환합니다.
  • 동작: FFmpeg을 사용하여 MP4 파일을 HLS 형식으로 변환합니다.
 
 
<html> <head> <title>Hls.js demo - basic usage</title> </head> <body> <script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script> <div style="text-align: center;"> <h1>Hls Demo</h1> <video height="600" id="video" controls></video> </div> <script> var video = document.getElementById('video'); var url = 'http://localhost:8080/good.m3u8'; if (Hls.isSupported()) { var hls = new Hls({ debug: true, }); hls.loadSource(url); hls.attachMedia(video); hls.on(Hls.Events.MEDIA_ATTACHED, function () { video.muted = true; // 음소거 된 영상만 자동 재생됨 (정책임) video.play(); }); } else if (video.canPlayType('application/vnd.apple.mpegurl')) { video.src = url; video.addEventListener('canplay', function () { video.play(); }); } </script> </body> </html>
  • 역할: 사용자에게 HLS 스트림을 재생할 수 있는 웹 페이지를 제공합니다.
  • 동작: Hls.js 라이브러리를 사용하여 http://localhost:8080/good.m3u8 URL에서 HLS 스트림을 로드하고 재생합니다.
  • 지원: 브라우저가 Hls.js를 지원하지 않으면, 기본 HTML5 비디오 플레이어를 사용하여 HLS 스트림을 재생합니다.
 
구체적인 엮임
  • HTML 페이지에서 http://localhost:8080/good.m3u8 URL을 사용하여 HLS 스트림을 로드합니다.
  • 해당 URL은 스프링 부트 애플리케이션의 @GetMapping("/{hlsName}.m3u8") 엔드포인트와 연결됩니다.
  • 이 엔드포인트는 hlsService.getVideoRes(hlsName + ".m3u8") 메소드를 호출하여 M3U8 파일을 제공하고, 이 파일에는 TS 파일 목록이 포함된다.
  • 클라이언트의 Hls.js는 M3U8 파일을 파싱하여 각 TS 파일을 순차적으로 요청합니다.
  • 각 TS 파일 요청은 스프링 부트 애플리케이션의 @GetMapping("/{tsName}.ts") 엔드포인트와 연결되어 TS 파일을 제공한다.
 
 
 
spring.mvc.static-path-pattern=/videos/** spring.resources.static-locations=file:videos/
<div class="row"> <div class="col-md-4 trailer-item"> <video height="600" id="video" controls></video> <button class="play-button" data-trailer-id="{{model.trailerId}}">&#9658;</button> <div class="trailer-info"> <span class="badge">HD</span> <span>메인 예고편</span> </div> <div class="trailer-info"> <span>2024.03.14</span> </div> </div> </div> <script> $(document).ready(function() { $('.play-button').on('click', function() { var trailerId = $(this).data('trailer-id'); $.ajax({ url: '/videos/' + trailerId + '/master.m3u8', method: 'GET', success: function(response) { var video = document.getElementById('video'); if (Hls.isSupported()) { var hls = new Hls(); hls.loadSource('/videos/' + trailerId + '/master.m3u8'); hls.attachMedia(video); hls.on(Hls.Events.MANIFEST_PARSED, function() { video.play(); }); } else if (video.canPlayType('application/vnd.apple.mpegurl')) { video.src = '/videos/' + trailerId + '/master.m3u8'; video.addEventListener('loadedmetadata', function() { video.play(); }); } }, error: function(error) { console.error('Error loading master m3u8:', error); } }); }); }); </script>
notion image
@ControllAdvice 알아보기
 
 
@Entity public class Screening { // other fields... @ManyToOne @JoinColumn(name = "theater_id") @JsonBackReference private Theater theater; @Override public String toString() { return "Screening{id=" + id + ", date=" + date + "}"; } } @Entity public class Theater { // other fields... @OneToMany(mappedBy = "theater") @JsonManagedReference private List<Screening> screenings; @Override public String toString() { return "Theater{id=" + id + ", name=" + name + "}"; } }
 
@Entity public class Screening { // other fields... @ManyToOne @JoinColumn(name = "theater_id") private Theater theater; @Override public String toString() { return "Screening{id=" + id + ", date=" + date + "}"; } } @Entity public class Theater { // other fields... @OneToMany(mappedBy = "theater") private List<Screening> screenings; @Override public String toString() { return "Theater{id=" + id + ", name=" + name + "}"; } }
Share article

p4rksk