Skip to content

Feature(#180): 발표자의 영상과 음성 스트림을 파일로 저장 #181

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged

Conversation

platinouss
Copy link
Member

@platinouss platinouss commented Dec 2, 2023

작업 개요

  • 추후 다시 보기 기능을 지원하기 위해, 발표자의 영상과 음성을 파일로 저장해야 했습니다.
  • 발표자의 영상 및 음성 스트림이 프레임(또는 샘플) 단위로 들어오면 각각 버퍼에 저장해두고, 강의 종료 시 버퍼에 저장된 영상과 음성 데이터를 하나의 파일로 병합했습니다.

작업 사항

강의 종료 시 발표자의 영상과 음성 스트림을 파일로 저장한다 close #180
타입스크립트로 node-webrtc 모듈을 사용하기 위한 타입 정의 파일 추가 close #28

고민한 점들(필수 X)

node 환경에서 지원하는 WebRTC의 타입스크립트 미지원 문제

현재 Node의 WebRTC 모듈은 타입스크립트를 지원하지 않아서, 직접 타입을 지정해줘야 한다.

이전에 WebRTC의 타입을 추가해줬다면 하단 부분을 node_modules/wrtc/lib/index.d.ts 최하단에 추가해준다 (만약 타입을 지정해 준적이 없다면 #120 PR을 참고)

export declare var nonstandard: {
  RTCAudioSource: {
    prototype: RTCAudioSource,
    new(): RTCAudioSource
  },
  RTCAudioSink: {
    prototype: RTCAudioSink,
    new(track: MediaStreamTrack): RTCAudioSink
  },
  RTCVideoSource: {
    prototype: RTCVideoSource,
    new(init?: RTCVideoSourceInit): RTCVideoSource
  },
  RTCVideoSink: {
    prototype: RTCVideoSink,
    new(track: MediaStreamTrack): RTCVideoSink
  },
  i420ToRgba(
    i420Frame: { width: number, height: number, data: Uint8ClampedArray },
    rgbaFrame: { width: number, height: number, data: Uint8ClampedArray },
  ): void,
  rgbaToI420(
    i420Frame: { width: number, height: number, data: Uint8ClampedArray },
    rgbaFrame: { width: number, height: number, data: Uint8ClampedArray },
  ): void,
}

export interface RTCAudioSource {
  createTrack(): MediaStreamTrack;
  onData(data: RTCAudioData): void;
}

export interface RTCAudioData {
  samples: Int16Array;
  sampleRate: number;
  bitsPerSample?: 16;
  channelCount?: 1;
  numberOfFrames?: number;
}

export interface RTCAudioSink extends EventTarget {
  stop(): void;
  readonly stopped: boolean;
  ondata: ((this: RTCAudioSink, ev: RTCAudioDataEvent) => any) | null;
  addEventListener(type: "data", listener: DataEventListener | DataEventListenerObject | null, options?: boolean | AddEventListenerOptions): void;
  removeEventListener(type: "data", callback: DataEventListener | DataEventListenerObject | null, options?: EventListenerOptions | boolean): void;
}

export interface RTCAudioDataEvent extends RTCAudioData, Event {
  type: 'data';
}

interface DataEventListener extends EventListener {
  (data: RTCAudioDataEvent): void
}

interface DataEventListenerObject extends EventListenerObject {
  handleEvent(evt: RTCAudioDataEvent): void;
}

export interface RTCVideoSourceInit {
  isScreencast?: boolean;
  needsDenoising?: boolean;
}

export interface RTCVideoSource {
  readonly isScreencast: boolean;
  readonly needsDenoising?: boolean;
  createTrack(): MediaStreamTrack;
  onFrame(frame: RTCVideoFrame): void;
}

export interface RTCVideoFrame {
  width: number;
  height: number;
  data: Uint8ClampedArray;
  rotation?: number;
}

export interface RTCVideoSink {
  stop(): void;
  readonly stopped: boolean;
  onframe: ((this: RTCVideoSink, ev: RTCVideoFrameEvent) => any) | null;
  addEventListener(type: "data", listener: FrameEventListener | FrameEventListenerObject | null, options?: boolean | AddEventListenerOptions): void;
  removeEventListener(type: "data", callback: FrameEventListener | FrameEventListenerObject | null, options?: EventListenerOptions | boolean): void;
}

export interface RTCVideoFrameEvent extends Event {
  type: 'frame';
  frame: RTCVideoFrame;
}

interface FrameEventListener extends EventListener {
  (data: RTCVideoFrameEvent): void
}

interface FrameEventListenerObject extends EventListenerObject {
  handleEvent(evt: RTCVideoFrameEvent): void;
}

WebRTC를 통해 전송되는 미디어 데이터 추출하기

작성 예정

버퍼에 저장된 영상 및 음성 데이터를 하나의 파일로 병합하기

작성 예정

발표자의 음성 또는 영상을 프레임 단위로 받고, 해당 데이터를 버퍼에 저장한다. 이후 강의가 종료되면 버퍼에 저장되어 있던 데이터를 ffmpeg 모듈을 이용하여 mp4 파일로 추출한다.
@platinouss platinouss added ✨ Feat 기능 개발 BE 백엔드 작업 labels Dec 2, 2023
@platinouss platinouss added this to the 4주차 milestone Dec 2, 2023
@platinouss platinouss self-assigned this Dec 2, 2023
@platinouss platinouss requested a review from tmddus2 December 2, 2023 11:28
Comment on lines +43 to +50
tracks.getTracks().forEach((track) => {
if (track.kind === 'video') {
videoSink = new RTCVideoSink(track);
}
if (track.kind === 'audio') {
audioSink = new RTCAudioSink(track);
}
});
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

발표자 track이 추가될 때 각 track을 받아서 sink 객체를 만들어주는거 같은데, sink 객체가 어떤 걸 하는걸까요? 지금 찾아봤을 때는 frame 이벤트를 발생시켜준다고 하는데 어떤 역할을 하는 객체인지 궁금합니다!

Copy link
Member Author

@platinouss platinouss Dec 2, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아마 이 도큐먼트를 보셨을텐데, 해당 도큐먼트에 나와있는대로 추가한 트랙에서 프레임(또는 샘플) 단위로 데이터를 받을 수 있도록 구현해놓은 node-WebRTC의 API입니다 (미디어 데이터에서 특정 크기의 데이터를 복사해서 가져온 후 frame(또는 data) 이벤트를 발생시키는 것 같습니다)

대강 어떻게 흘러가는지 예측해볼 순 있지만, 정확한 정보는 구현체를 보는 방법밖에 없어서 더 궁금하시다면 해당 코드를 참고해보시면 될 것 같습니다.
https://github.com/node-webrtc/node-webrtc/blob/develop/src/interfaces/rtc_audio_sink.cc
https://github.com/node-webrtc/node-webrtc/blob/develop/src/interfaces/rtc_video_sink.cc

Comment on lines 68 to 70
audioSink.ondata = ({ samples: { buffer } }) => {
this.pushData(stream, buffer);
};
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 이벤트는 sink 객체에 data가 들어올 때 발생하는 이벤트인가요?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

네 특정 단위의 발표자 음성 데이터를 받았을 때 data 이벤트가 발생합니다

Comment on lines 72 to 81
const stream = this.peerStreams.get(roomId) as PeerStream;
stream.video.push(Buffer.from(data));
};
};

pushData = (stream: PeerStream, buffer: ArrayBufferLike) => {
if (!stream.end) {
stream.audio.push(Buffer.from(buffer));
}
};
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stream.video.push는 video 데이터들을 video stream에 저장하는거고, stream.audio.push는 audio 데이터들을 audio stream에 저장하는거 같은데 audio는 ondata 이벤트가 호출 될 때 저장되고 video는 onframe 이벤트에 호출되나요? ondata와 onframe 이벤트가 어떤 차이가 있는지 궁금합니다!

Copy link
Member Author

@platinouss platinouss Dec 2, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

맞습니다 이벤트 이름만 다를 뿐 역할은 똑같습니다. 아마도 영상은 I420 프레임 단위로 들어오다 보니 data가 아닌 frame 이벤트로 지은 것 아닐까 싶습니다.

자세한 부분은 해당 링크를 참고해보면 좋을 것 같습니다.
https://github.com/node-webrtc/node-webrtc/blob/develop/docs/nonstandard-apis.md#programmatic-audio

Comment on lines 87 to 91
const proc = ffmpeg()
.addInput(videoPath)
.addInputOptions(videoConfig('640x480'))
.addInput(audioPath)
.addInputOptions(audioConfig)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ffmpeg input들은 path로 넣어주나보군요..!

Copy link
Member Author

@platinouss platinouss Dec 2, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

네 아까 만들었던 sock 파일 경로를 추가해줍니다

Comment on lines 92 to 94
.on('start', () => {
console.log(`${roomId} 강의실 영상 녹화 시작`);
})
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setFfmpeg 함수가 강의가 종료되고 호출되는 함수같은데 이 start 이벤트가 강의가 시작될 때 실행되는 이벤트인가요? 혹시 이 이벤트가 언제 발생되나요??

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ffmpeg가 생성될 때(이 코드에서는 102번 라인이 실행됐을 때) start 이벤트가 발생됩니다.
https://github.com/fluent-ffmpeg/node-fluent-ffmpeg#start-ffmpeg-process-started

};

mediaStreamToFile = (stream: PassThrough, fileName: string): string => {
const outputPath = path.join(outputDir, `${fileName}.sock`);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sock 파일 형식으로 저장하신 이유가 있나요??

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

보통 프로세스 간 통신을 위한 파일로 sock 확장자를 사용한다고 하더군요 ! 구분하기 위해서 작성했고 다른 의미는 없습니다

Comment on lines 118 to 119
fs.unlinkSync(path.join(outputDir, `video-${roomId}.sock`));
fs.unlinkSync(path.join(outputDir, `audio-${roomId}.sock`));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sync 함수를 쓰신 이유가 있나요?? 특별한 이유가 없으면 그냥 unlink 함수를 쓰는게 좋을 것 같습니다!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

좋습니다 !

Comment on lines 113 to 114
const stream = this.peerStreams.get(roomId);
const sinkList = this.peerSinks.get(roomId);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

네이밍이 stream은 그냥 stream인데 sink는 sink가 아니라 sinkList인 이유가 있나요? 제가 코드 이해한 바로는 peerStreamspeerSinks나 둘다 Map 객체여서 get 하면 객체 하나가 나올 것 같아서요!

Copy link
Member Author

@platinouss platinouss Dec 2, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기서 stream은 발표자의 스트림 정보가 담겨있는 객체(59번 라인)이고, sinkList에는 음성 sink & 영상 sink가 담겨있기 때문에 list를 붙였습니다. 다시보니 직관적이지는 않은 것 같아서 한번 수정해보겠습니다

Comment on lines +123 to +130
function end() {
socket.emit('end', {roomId: 1})
presenterRTCPC.close();
presenterRTCPC = null;
startButton.disabled = false;
endButton.disabled = true;
}

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 end는 어디서 호출되나요?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

132번 라인 보시면 endButton(방 나가기)을 눌렀을 때 호출됩니다 !

@tmddus2 tmddus2 self-requested a review December 2, 2023 15:31
Copy link
Collaborator

@tmddus2 tmddus2 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

상세한 설명 감사드립니다! 수정하신다고 하는 부분만 수정해서 올려주시면 머지할게요 👍🏻

@tmddus2 tmddus2 self-requested a review December 3, 2023 02:46
발표자의 영상 프레임과 음성 샘플을 저장해둔 임시파일을 병합 후 삭제해야 하는데 이때 비동기로 처리하도록 변경한다.
추후 확장성을 고려하여 영상 및 음성 녹화에 필요한 정보를 클래스 단위로 모듈화했습니다.
미디어 파일 절대 경로 지정 및 임시 파일 삭제 기능 모듈화 적용
@boostcampwm2023 boostcampwm2023 deleted a comment from netlify bot Dec 3, 2023
Copy link
Collaborator

@tmddus2 tmddus2 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

수고 많으셨습니다! 머지할게요

@tmddus2 tmddus2 merged commit 371891e into boostcampwm2023:dev Dec 3, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
BE 백엔드 작업 ✨ Feat 기능 개발
Projects
None yet
2 participants