Electron+React 프로그램에서 영상 내보내기 기능 구현

우디·2024년 2월 22일
0
post-thumbnail

안녕하세요:) 개발자 우디입니다! 아래 내용 관련하여 작업 중이신 분들께 도움이되길 바라며 글을 공유하니 참고 부탁드립니다😊
(이번에 벨로그로 이사오면서 예전 글을 옮겨적었습니다. 이 점 양해 부탁드립니다!)

작업 시점: 2021년 7월

배경

  • 기존에는 사용자들이 편집한 영상을 바로 확인할 수 없어서 불편했음 → 편집한 영상을 바로 확인 가능하도록 자체 영상 내보내기 기능을 구현함

사전 생각 및 고민

  • 영상이 내보내기 되는 과정이 어떻게 되지?
    1. 프로그램 내에서 편집된 영상 클립들을 내보내기
    2. 내보낸 클립들을 하나의 영상 파일로 병합하기
  • 내보낸 영상 파일들을 어디에 저장하지?
    • 윈도우에는 임시 폴더라는 곳이 있음.
      • 임시 폴더 관련 알아보기
        • 컴퓨팅에서 임시 폴더 또는 임시 디렉토리는 임시 파일을 보관하는 데 사용되는 디렉토리
        • 많은 운영 체제와 일부 소프트웨어는 시작 시 또는 주기적으로 이 디렉토리의 내용을 자동으로 삭제하고 디렉토리 자체는 그대로 둠
        • MS-DOS 및 Microsoft Windows에서 임시 디렉토리는 환경 변수 TEMP 또는 TMP로 설정. Window API를 사용하면 GetTempPath2 함수를 사용하여 임시 디렉토리 경로를 찾거나 GetTempFileName 함수를 사용하여 고유한 이름의 임시 파일 경로를 얻을 수 있음. 원래 기본값은 C:\Temp, 그 다음 %WinDir%\Temp였음. Windows XP 시절에는 사용자별로 임시 디렉터리가 Local Settings\Temp로 설정되었지만 여전히 사용자가 재배치할 수 있었음. Windows Vista, 7, 8 및 10의 경우 임시 위치가 사용자 프로필의 AppData 섹션으로 다시 이동되었음.
      • => 임시 폴더에 클립들을 저장한 후에 이를 병합하여 영상 출력 결과물을 사용자에게 보여주기로 결정.
  • 사용자의 클릭은 렌더러 프로세스에서 발생하는데, 영상 출력은 메인 프로세스에서 처리되어야 하나?
    • 일렉트론 메인 프로세스와 렌더러 프로세스 관련 알아보기
      • 일렉트론은 내부적으로 메인 프로세스와 렌더러 프로세스(main process, renderer process) 구조를 가짐
      • 메인 프로세스
        • 단일 메인 프로세스가 여러 개의 렌더러 프로세스를 관리하는 구조
        • 응용프로그램 창(window) 의 생성 및 삭제 등을 관리하는 역할을 메인 프로세스에서 하는 것이며, 메인 프로세스는 node 기반으로 동작하므로 파일 시스템 접근 등 Node.js API(https://nodejs.org/dist/latest-v16.x/docs/api/)를 모두 사용할 수 있음
      • 렌더러 프로세스
        • 렌더러 프로세스는 응용프로그램 창 안에서 띄워지게 될 화면에 대해 관여
        • 렌더러 프로세스에서 무언가 메인 프로세스에서만 할 수 있는 일(메뉴 표시, 다이얼로그, 윈도우 관련, Node.js API 기능 등)이 필요할 경우, react 등의 기존 자바스크립트 코드(렌더러 프로세스)에서는 접근할 수 없는 일이기 때문에, 프로세스 간 통신(IPC, Inter-Process Communication)으로 메인 프로세스에게 요청을 해야 함
      • ⇒ 영상 출력 과정에서 파일 시스템 접근 등의 Node.js API를 사용해야 하기 때문에, IPC 통신을 하여 main.js 에서 코드를 작성하기로 결정.

구현 과정

  • main.js 에 영상 클립 내보내서 하나의 파일로 병합하는 함수 makeClipsAndMerge

    • 클립으로 내보내기

      let makeClip = ffmpeg(videoPath);
      ...생략...
      makeClip
      	.setStartTime(startTime)
      	.setDuration(endTime - startTime)
      	.withSize(resolution)
      	.outputOptions(videoQuality)
      	.output(savedClipPath)
      	.on('progress', function (progress) {
      	  // 내보내기 과정의 progress를 파악하기 위함
      		...생략...
      	})
      	.on('end', function (err) {
      		// 내보낸 클립들 평합하는 코드 삽입
      		...생략...  
      	})
      	.on('error', function (err) {
      		// 에러 발생 시 실행될 코드 삽입
      		...생략...
      	})
      	.run();
      • ffmpeg의 인스턴스인 makeClip에 시작 시각, 클립 길이, 해상도, 클립 저장 경로 등을 설정
      • 내보내기 진행 과정, 완료 후, 에러 발생 시 각각 실행될 코드들 삽입
      • .on() 은 OS의 Interrupt와 같이 동작
      • 파일처리 과정에서 활용되는 path.parse() 관련 알아보기
        • 파일 경로를 인자로 받아, root, dir, base, ext, name으로 분리한 후 해당 내용을 담은 객체값을 리턴함
        • 윈도우에서의 path.parse('C:\path\dir\file.txt') 리턴 값
          {
          	root: 'C:\\',
          	dir: 'C:\\path\\dir',
          	base: 'file.txt',
          	ext: '.txt',
          	name: 'file'
          }
    • 내보낸 클립 병합하기

      let mergedVideo = ffmpeg();
      
      ...생략...
      
      videoNames.forEach(function (videoName) {
        mergedVideo.addInput(videoName);
      });
      
      mergedVideo
        .mergeToFile(selectedFilePath, tmpPath)
        .outputOptions(videoQuality)
        .on('progress', function (progress) {
          // 병합 과정의 progress를 파악하기 위함
      		...생략...
        })
        .on('error', function (err) {
          // 내보낸 클립들 평합하는 코드 삽입
      		...생략...  
        })
        .on('end', function () {
          // 에러 발생 시 실행될 코드 삽입
      		...생략...
        });
      • mergedVideo 인스턴스에 병합하고자 하는 클립들의 경로를 addInput 해줌
      • mergeToFile 메서드 활용하여 클립 병합
        • 여러 입력 파일을 단일 파일로 출력하기 위한 메서드
        • 두 번째 인자로 임시 폴더 경로 필요
    • 속도 높이기 위한 하드웨어 가속

      • 클립 생성 및 병합 과정에서 아래와 같이 inputOptions 설정하면 됨

        .inputOptions([
          '-hwaccel cuda',
          '-c:v h264_cuvid',
        ])
      • 사용자 하드웨어 환경 파악

        • 사용자의 하드웨어가 CUDA 지원 그래픽카드를 탑재하고 있는지 확인해야 함
        • detect-gpu 라이브러리 활용
          import { getGPUTier } from 'detect-gpu';
          this.isCudaSupported = '';
          ...생략...
          detectUserGpu = () => {
            (async () => {
              const userGpu = await getGPUTier();
              // Example output:
              // {
              //   device: undefined
              //   fps: 694
              //   gpu: "nvidia geforce rtx 3070"
              //   isMobile: false
              //   tier: 3
              //   type: "BENCHMARK"
              // }
          
              let isNvidiaContained = userGpu['gpu'].toUpperCase().includes('NVIDIA');
              let isProperTier = userGpu['tier'] >= 2;
          
              //NVIDIA 포함, Tier가 2이상일 경우만 true
              if (isNvidiaContained & isProperTier) {
                this.isCudaSupported = true;
              } else {
                this.isCudaSupported = false;
              }
            })();
          };
      • 가속 여부 테스트

        • 가속 코드를 제거했을 때
        • 가속 코드를 추가했을 때
        • ⇒ 가속 코드를 추가했을 때 GPU 사용률이 높아지는 것을 보면 가속 옵션이 제대로 적용된 것으로 보임
    • 화질 옵션 고려하기

      • 사용자가 선택한 옵션에 따라 출력되는 영상의 화질이 달라져야 함

      • 출력되는 결과에 대한 옵션이니까 fluent-ffmpeg 의 outputOptions 을 활용하면 되지 않을까 생각함

      • 영상의 화질을 결정하는 요소?

        • 해상도
          • FHD 해상도는 가로 1920픽셀, 세로 1080픽셀로 구성됨. 이는 TV나 모니터 등 출력장치의 크기와는 별개로, 파일 자체가 가지고 있는 화질은 균일함
        • 프레임 레이트
          • 해상도와 함께 중요한 요소 중 하나인 프레임 레이트는, 1초에 몇 장의 사진을 보여주는지를 나타냄. FPS(Frame Per Second)로 표시하기도 하고 ‘프레임’으로 줄여 부르기도 함. 보편적인 기준으로 영화관에서 상영되는 영상은 24프레임, TV 방송은 30프레임, PC 게임은 60프레임이라고 생각하면 프레임 레이트에 의한 영상의 차이를 쉽게 알 수 있음
        • 비트레이트
          • 초당 영상을 구성하는 데이터의 양. 표기는 BPS(Bit Per Second)로 하는데, 통신 속도를 뜻할 때와 상통하는 의미로 봐도 무방함. 1초에 얼마나 많은 데이터 양을 집적하는지에 따라 영상의 품질이 결정되는데, 너무 낮은 비트레이트를 설정하면 화면 전체에 걸쳐 네모나게 깨지는 현상을 보임
        • 주사방식
          • FHD를 1080P라고도 표기. 영상을 출력장치에 어떻게 뿌려주는지에 따라 2가지 주사 방식으로 나누는데, 숫자 뒤의 P는 순차주사(프로그레시브, Progressive) 방식을 뜻함. 현재 공중파 방송 송출은 1080i로, 비월주사(인터레이스, Interace) 방식으로 송출된다는 뜻. 두 방식의 차이는 이미지를 송출하는 방식에 있음. 인터레이스 방식은 세로 픽셀을 기준으로 이미지를 절반으로 나눠, 한 프레임마다 번갈아가며 화면을 송출함. 프로그레시브 방식은 이미지를 나누지 않고 하나의 전체 이미지를 송출
        • 플랫폼
          • 결국 디지털 영상 파일을 보여주는 출력장치에 따라 최종 화질이 결정된다. 4K의 고화질 영상이라 해도 출력 해상도가 1366x768인 모니터로 보면 그 정밀함을 알 수 없음
      • 옵션에 따른 화질 테스트

        • 아무 코드도 추가하지 않았을 경우
          • 보통 화질의 경우와 초고화질의 경우 모든 비디오 속성값이 동일함
        • 클립 생성 단계에서 outputOptions 을 주었을 때
          • 보통 화질의 경우보다 초고화질의 경우가 ‘데이터 속도’, ‘총 비트 전송률’ 속성에서 월등히 높음.
        • 클립 병합 단계에서 outputOptions 을 주었을 때
          • 아무 코드도 추가하지 않았을 경우와 마찬가지로 보통 화질의 경우와 초고화질의 경우 모든 비디오 속성값이 동일함
        • ⇒ 아래와 같이 클립 생성 단계에서 outputOptions 을 활용하여 화질 옵션 설정 가능한 것으로 보임.
          function makeClipsAndMerge(...) {
            ...
              makeClip
                .setStartTime(startTime)
                .setDuration(endTime - startTime)
                .inputOptions([
                  "-hwaccel cuda",
                  "-c:v h264_cuvid"
                ])
                .videoCodec("h264_nvenc")
                .withSize(resolution)
                .outputOptions(videoQuality)
                .output(savedClipPath)
              ...
          }
        • 사실 눈으로 봤을 때는 화질에 있어서 그렇게 큰 차이가 나지는 않음.
          • → 하지만 ‘데이터 속도’, ‘총 비트 전송률’ 이 높은 것을 볼 때, 더 많은 양의 데이터를 더 높은 속도로 전달하기 때문에 더 높은 화질이라고 볼 수 있을 것임.
      • 기타 공유하고 싶은 점

        • MP4일 때는 '-crf(Constant rate factor)'옵션만 적용됨. -q:v(a way of representing variable bitrate qualities) 옵션은 무시. 다른 확장자의 경우에는 반대임.

중간에 발생한 이슈들

  • 이슈1) memory leak 문제 발생

    • 몇 개의 클립인지는 정확하지 않지만, 많은 클립에 대해 영상 내보내기 작업 시 메모리 leak 현상이 발생함.
    • 경고 메시지를 보자면,
      • 등록 가능한 리스너의 초과 한도를 넘은 것으로 보임.
      • 11개의 kill-ffmpeg 리스너가 ipcMain에 추가되었다고.
      • 한도를 높이기 위해서 setMaxListeners 를 사용하라고 함
    • 원인 파악 및 해결
      • 이 작업을 동료 개발자 분과 협업 중이었는데, 그 분이 kill-ffmpeg를 너무 많이 추가해서 발생한 문제로 파악됨 → kill-ffmpeg 리스너 등록하는 코드를 최적화하여 해결
      • 추가적으로 mergedVideo 생성자에 클립 이름들을 추가하는 부분에서 다른 옵션이 추가되는 것이 메모리 차지에 영향이 있을 것으로 보고, 이 부분도 개선해줌.
        // 기존 코드
        videoNames.forEach(function(videoName){
          mergedVideo
            .addInput(videoName)
            .videoBitrate(videoQuality)
        });
        
        // 개선된 코드
        videoNames.forEach(function(videoName){
          mergedVideo.addInput(videoName)
        });
  • 이슈2) 클립 생성은 잘 되는데, 병합 단계에서 에러 발생하는 경우

    • 약 60개 정도의 클립에 대해서 생성은 잘 되는데, 병합이 시작되고 나서 아래와 같은 에러 메시지가 뜨면서 병합이 정상적으로 진행되지 않는 현상 발생
      Error ffmpeg exited with code 1: Cannot find a matching stream for unlabeled input pad 114 on filter Parsed_concat_0
    • 원인 파악 및 해결 시도
      • 먼저 에러 메시지를 보자면
        • ffmpeg에서 보내준 메시지인 것 같고,
        • 뭔가 매칭하는 스트림 영상을 찾지 못한 것 같음
        • 뭔가 정상적으로 라벨되어야 하는데, 라벨 되지 않는 인풋 영상이 들어가서 그런 건가 싶음
      • 클립 개수의 문제인가?
        • 근데 더 많은 수의 클립에 대해서 영상 내보내기 했을 때애도 잘 진행됐음 → 개수는 원인이 아닌 것으로 보임.
      • 하드웨어 가속의 문제인가?
        • 가속을 사용한 경우, 하용하지 않은 경우 다 잘 됐음 → 하드웨어 가속도 원인이 아닌 것으로 보임.
      • 메모리 차이인가? 해상도의 차이인가? → 뭔가 하드웨어적인 문제라기 보다는 소프트웨어 쪽 인풋값 부분에서 뭔가를 찾지 못해서 발생하는 것 같음.
    • 원인을 찾았는데 영상 내보내기 단의 문제가 아니었음!
      • 마커 생성 관련하여 영상의 끝 지점 보다 더 뒤에 마커가 생성된 경우에 발생하는 에러였음.
      • 원본 영상에서 시간대로 자르는 방식으로 클립이 생성되는데, 애초에 원본 영상에 없는 시간대에 대한 클립이 생성되니까 생성된 클립이 정상적이지 못했을 것이고, 이에 따라 병합할 때 적절하게 라벨된 매칭하는 스트림을 찾을 수 없었던 것임.
      • 왜 영상보다 뒤에 마커가 생성되는 경우가 있는 것인가?
        • 더미 데이터인가?
          • 확인해보니 실제 데이터임
        • 프론트 초기 작업 당시, 영상 길이보다 길 경우 잘리도록 처리했다고 함.
          • 사용자가 갖고 있는 영상이 부분 영상이거나, 잘린 영상일수도 있어서 이런 처리를 해줬다고 함
            • ex) 채팅 데이터는 그대로 쭉 받아오는데, 영상은 앞의 한 시간 부분일수도 있음
          • 근데 마커에 대해서는 이 처리가 제대로 적용되지 않는 것 같음 → 마커에 대해 따로 처리 해줌으로써 문제 해결
  • 이슈3) 하드웨어 가속 안되는 경우 발생

    • 한 팀원이 하드웨어 가속이 안 된다는 문제 공유해줌
      • 집 컴퓨터에서 테스트 하고 있었는데, 그래픽 카드가 있음에도 가속이 되지 않는다고 함.
    • 그래서 콘솔에 찍히는 그래픽카드 모델을 알려달라고 했는데, 알고 보니 뒤에 3GB가 붙어서 가속 여부가 계속 false로 처리되었던 것임
    • 파악된 원인 해결
      • 기존에는 하드웨어 가속 가능한 모델 목록 중에 사용자의 그래픽카드 모델과 일치하는 것이 있는지를 includes() 활용해서 판단했는데, 이런 다양한 경우까지는 고려하지 못하는 것 같음.
      • 그래서 사용자의 그래픽 카드 모델에 ‘NVIDIA’ 키워드가 포함되는지 여부를 판단하는 방식으로 수정함.

배우고 느낀 점

  • 하드웨어 가속 관련해서 ffmpeg 옵션들이나, GPU 관련해서 미리 알아본 후 작업했다면 더 좋았을 것
  • 여러 컴퓨터 및 다양한 환경에서 테스트 해보는게 정말 중요하다는 것을 크게 느낌
  • 완벽할 순 없겠지만, 다양한 예외 상황들을 발견하고 해결하는 과정에서 완성도를 높여간다는 것에 뿌듯함을 느낌. 앞으로도 최대한 다양하게 고려하도록 노력하자.
  • 직접 테스트 해보면서 확실하게 확인하는 것이 중요함을 느낌
profile
넓고 깊은 지식을 보유한 개발자를 꿈꾸고 있습니다:) 기억 혹은 공유하고 싶은 내용들을 기록하는 공간입니다

0개의 댓글